Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71d678c2d | ||
|
|
f8e1022a41 | ||
|
|
2e5996463d |
@@ -13,7 +13,6 @@ from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any
|
||||
from message_handoff import stable_event_id
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -56,18 +55,13 @@ class AgentEventBus:
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = stable_event_id(
|
||||
source=source, target=target, message_type=kind, payload=payload,
|
||||
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||
)
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
"""HTTP API for AIOS external-agent online task distribution.
|
||||
|
||||
The service is intended to bind to loopback and be published through the
|
||||
existing TLS reverse proxy. Control operations use a server-managed control
|
||||
Bearer token. Agents receive individual Bearer tokens at registration; only
|
||||
token hashes are persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
MAX_BODY = 1024 * 1024
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class APIConfig:
|
||||
db_path: Path
|
||||
control_token_path: Path
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 18940
|
||||
token_pepper: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "APIConfig":
|
||||
return cls(
|
||||
db_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_DB",
|
||||
"/var/lib/aios-agent-task/state.sqlite3",
|
||||
)),
|
||||
control_token_path=Path(os.getenv(
|
||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
||||
"/run/credentials/aios-agent-task-api.service/control_token",
|
||||
)),
|
||||
host=os.getenv("AIOS_AGENT_TASK_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("AIOS_AGENT_TASK_PORT", "18940")),
|
||||
token_pepper=os.getenv("AIOS_AGENT_TASK_TOKEN_PEPPER", ""),
|
||||
)
|
||||
|
||||
|
||||
class AgentTaskApplication:
|
||||
def __init__(self, config: APIConfig) -> None:
|
||||
self.config = config
|
||||
self.store = TaskStore(
|
||||
config.db_path,
|
||||
token_pepper=config.token_pepper,
|
||||
)
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = self.config.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_UNAVAILABLE", 503)
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise TaskProtocolError("CONTROL_CREDENTIAL_INVALID", 503)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _bearer(headers: Mapping[str, str]) -> str:
|
||||
value = str(headers.get("Authorization") or "")
|
||||
if not value.startswith("Bearer "):
|
||||
return ""
|
||||
return value[7:].strip()
|
||||
|
||||
def authenticate_control(self, headers: Mapping[str, str]) -> None:
|
||||
supplied = self._bearer(headers)
|
||||
expected = self._control_token()
|
||||
if not supplied or not hmac.compare_digest(supplied, expected):
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
|
||||
def authenticate_agent(self, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
return self.store.authenticate_agent(self._bearer(headers))
|
||||
|
||||
def principal(self, headers: Mapping[str, str]) -> tuple[str, dict[str, Any] | None]:
|
||||
supplied = self._bearer(headers)
|
||||
if not supplied:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
try:
|
||||
expected = self._control_token()
|
||||
except TaskProtocolError:
|
||||
expected = ""
|
||||
if expected and hmac.compare_digest(supplied, expected):
|
||||
return "control", None
|
||||
return "agent", self.store.authenticate_agent(supplied)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "ok",
|
||||
"schema": "aios.agent-task.v1",
|
||||
"task_states": [
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
],
|
||||
"lease_recovery": True,
|
||||
"atomic_claim": True,
|
||||
"idempotent_result_submission": True,
|
||||
"server_side_verification_required": True,
|
||||
"plaintext_credentials_persisted": False,
|
||||
}
|
||||
|
||||
def register_agent(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.register_agent(
|
||||
agent_id=str(body.get("agent_id") or ""),
|
||||
agent_type=str(body.get("agent_type") or ""),
|
||||
agent_version=str(body.get("agent_version") or ""),
|
||||
capabilities=body.get("capabilities") or [],
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
)
|
||||
|
||||
def create_task(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.create_task(
|
||||
task_id=str(body.get("task_id") or "") or None,
|
||||
goal=str(body.get("goal") or ""),
|
||||
instructions=str(body.get("instructions") or ""),
|
||||
success_criteria=body.get("success_criteria"),
|
||||
required_capabilities=body.get("required_capabilities") or [],
|
||||
constraints=body.get("constraints") if isinstance(body.get("constraints"), Mapping) else {},
|
||||
metadata=body.get("metadata") if isinstance(body.get("metadata"), Mapping) else {},
|
||||
deadline=str(body.get("deadline") or "") or None,
|
||||
priority=body.get("priority", 0),
|
||||
max_attempts=body.get("max_attempts", 3),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
)
|
||||
|
||||
def poll(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
tasks = self.store.poll_tasks(
|
||||
agent_id=agent["agent_id"],
|
||||
limit=int(body.get("limit", 20)),
|
||||
)
|
||||
return {"agent_id": agent["agent_id"], "tasks": tasks}
|
||||
|
||||
def claim(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.claim_task(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def heartbeat(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
return self.store.heartbeat(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
progress=body.get("progress") if isinstance(body.get("progress"), Mapping) else {},
|
||||
lease_seconds=int(body.get("lease_seconds", 300)),
|
||||
)
|
||||
|
||||
def result(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent = self.authenticate_agent(headers)
|
||||
result = body.get("result")
|
||||
if not isinstance(result, Mapping):
|
||||
raise TaskProtocolError("RESULT_INVALID", 400)
|
||||
return self.store.submit_result(
|
||||
task_id=task_id,
|
||||
agent_id=agent["agent_id"],
|
||||
lease_token=str(body.get("lease_token") or ""),
|
||||
idempotency_key=str(body.get("idempotency_key") or ""),
|
||||
result=result,
|
||||
)
|
||||
|
||||
def verify(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.verify_task(
|
||||
task_id=task_id,
|
||||
decision=str(body.get("decision") or ""),
|
||||
verification=body.get("verification") if isinstance(body.get("verification"), Mapping) else {},
|
||||
)
|
||||
|
||||
def cancel(self, task_id: str, headers: Mapping[str, str], body: Mapping[str, Any]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return self.store.cancel_task(task_id=task_id, reason=str(body.get("reason") or ""))
|
||||
|
||||
def get_task(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
kind, agent = self.principal(headers)
|
||||
task = self.store.get_task(task_id)
|
||||
if kind == "agent" and task.get("claimed_by") != agent["agent_id"]:
|
||||
raise TaskProtocolError("FORBIDDEN", 403)
|
||||
return task
|
||||
|
||||
def events(self, task_id: str, headers: Mapping[str, str]) -> dict[str, Any]:
|
||||
self.authenticate_control(headers)
|
||||
return {"task_id": task_id, "events": self.store.get_events(task_id)}
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
app: AgentTaskApplication | None = None
|
||||
server_version = "AIOSAgentTaskAPI/1"
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {key: value for key, value in self.headers.items()}
|
||||
|
||||
def _json(self, value: Any, status: int = 200) -> None:
|
||||
payload = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError as exc:
|
||||
raise TaskProtocolError("CONTENT_LENGTH_INVALID", 400) from exc
|
||||
if size < 0 or size > MAX_BODY:
|
||||
raise TaskProtocolError("REQUEST_TOO_LARGE", 413)
|
||||
raw = self.rfile.read(size) if size else b"{}"
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise TaskProtocolError("JSON_INVALID", 400) from exc
|
||||
if not isinstance(value, dict):
|
||||
raise TaskProtocolError("JSON_OBJECT_REQUIRED", 400)
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _task_route(path: str) -> tuple[str, str | None]:
|
||||
match = re.fullmatch(
|
||||
r"/v1/tasks/([A-Za-z0-9][A-Za-z0-9._:-]{2,179})(?:/(claim|heartbeat|result|verify|cancel|events))?",
|
||||
path,
|
||||
)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return "", None
|
||||
|
||||
def _run(self, callback) -> None:
|
||||
try:
|
||||
callback()
|
||||
except TaskProtocolError as exc:
|
||||
self._json({"error": exc.code}, exc.http_status)
|
||||
except Exception as exc:
|
||||
self._json({"error": "INTERNAL_ERROR", "detail": type(exc).__name__}, 500)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path == "/health":
|
||||
self._json(self.app.health())
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if task_id and suffix is None:
|
||||
self._json(self.app.get_task(task_id, self._headers()))
|
||||
return
|
||||
if task_id and suffix == "events":
|
||||
self._json(self.app.events(task_id, self._headers()))
|
||||
return
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
body = self._body()
|
||||
if path == "/v1/agents/register":
|
||||
self._json(self.app.register_agent(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks":
|
||||
self._json(self.app.create_task(self._headers(), body), 201)
|
||||
return
|
||||
if path == "/v1/tasks/poll":
|
||||
self._json(self.app.poll(self._headers(), body))
|
||||
return
|
||||
task_id, suffix = self._task_route(path)
|
||||
if not task_id:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
return
|
||||
if suffix == "claim":
|
||||
self._json(self.app.claim(task_id, self._headers(), body))
|
||||
elif suffix == "heartbeat":
|
||||
self._json(self.app.heartbeat(task_id, self._headers(), body))
|
||||
elif suffix == "result":
|
||||
self._json(self.app.result(task_id, self._headers(), body))
|
||||
elif suffix == "verify":
|
||||
self._json(self.app.verify(task_id, self._headers(), body))
|
||||
elif suffix == "cancel":
|
||||
self._json(self.app.cancel(task_id, self._headers(), body))
|
||||
else:
|
||||
self._json({"error": "NOT_FOUND"}, 404)
|
||||
self._run(action)
|
||||
|
||||
|
||||
def serve(config: APIConfig | None = None) -> None:
|
||||
config = config or APIConfig.from_env()
|
||||
application = AgentTaskApplication(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
@@ -0,0 +1,827 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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',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 = set(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 = set(json.loads(row["required_capabilities_json"]))
|
||||
if required.issubset(capabilities):
|
||||
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 = set(json.loads(row["required_capabilities_json"]))
|
||||
if not required.issubset(capabilities):
|
||||
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=?,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)
|
||||
]
|
||||
@@ -94,91 +94,6 @@ class AuthorityGitExecutor:
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
return path
|
||||
|
||||
def recover_failed_detached_worktree_exact(
|
||||
self,
|
||||
*,
|
||||
path: Path,
|
||||
expected_oid: str,
|
||||
stale_oid: str,
|
||||
target_ref: str,
|
||||
) -> str:
|
||||
"""Recreate one exact clean, detached, single-attempt worktree at its durable head."""
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(stale_oid, "stale_oid")
|
||||
if not TASK_BRANCH_RE.fullmatch(target_ref):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
if self.read_ref(target_ref) is not None:
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.parent != self.worktree_root
|
||||
or path.name in {"", ".", ".."}
|
||||
or path.is_symlink()
|
||||
or not path.is_dir()
|
||||
):
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_PATH_INVALID")
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if head.returncode != 0 or head.stdout.strip() != stale_oid or stale_oid == expected_oid:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_HEAD_INVALID")
|
||||
symbolic = subprocess.run(
|
||||
["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if symbolic.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_DETACHED")
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if status.returncode != 0 or status.stdout:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_CLEAN")
|
||||
parents = self._git(["rev-list", "--parents", "-n", "1", stale_oid]).split()
|
||||
if parents != [stale_oid, expected_oid]:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_LINEAGE_INVALID")
|
||||
registered = [
|
||||
line.removeprefix("worktree ")
|
||||
for line in self._git(["worktree", "list", "--porcelain"]).splitlines()
|
||||
if line.startswith("worktree ")
|
||||
]
|
||||
if registered.count(str(path)) != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REGISTRATION_INVALID")
|
||||
self._git(["worktree", "remove", "--force", str(path)])
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REMOVE_FAILED")
|
||||
recreated = self.create_detached_worktree(expected_oid, path.name, path.parent)
|
||||
readback = subprocess.run(
|
||||
["git", "-C", str(recreated), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
detached = subprocess.run(
|
||||
["git", "-C", str(recreated), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if readback.returncode != 0 or readback.stdout.strip() != expected_oid or detached.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_READBACK_MISMATCH")
|
||||
return stale_oid
|
||||
|
||||
def create_task_branch_CAS(self, branch: str, expected_base_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_base_oid, "expected_base_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current is not None:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if not self.ancestry_check(expected_base_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, "0" * 40])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CREATE_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Private Unix-socket caller projection and rehydration client.
|
||||
|
||||
The broker response body is deliberately public metadata only. The short-lived
|
||||
credential projection is delivered in a private response header, consumed
|
||||
inside the model API process, and never returned by an HTTP API or persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
from typing import Any, Mapping
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
CREDENTIAL_HEADER = "X-AIOS-Caller-Projection-Credential"
|
||||
|
||||
|
||||
class CallerProjectionError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 503) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectionResult:
|
||||
public: dict[str, Any]
|
||||
credential_loaded: bool
|
||||
|
||||
|
||||
class _UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path: Path, timeout: float) -> None:
|
||||
super().__init__("localhost", timeout=timeout)
|
||||
self.socket_path = socket_path
|
||||
|
||||
def connect(self) -> None:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(self.timeout)
|
||||
try:
|
||||
sock.connect(str(self.socket_path))
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
self.sock = sock
|
||||
|
||||
|
||||
class CallerProjectionClient:
|
||||
def __init__(self, socket_path: Path, *, timeout: float = 2.0) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def _idempotency_key(caller: str, scope: str) -> str:
|
||||
digest = hashlib.sha256((caller + "\0" + scope).encode()).hexdigest()
|
||||
return "caller-projection-" + digest[:32] + "-v1"
|
||||
|
||||
def rehydrate(
|
||||
self,
|
||||
caller: str,
|
||||
scope: str,
|
||||
request: Mapping[str, Any] | None = None,
|
||||
) -> ProjectionResult:
|
||||
if caller != CALLER:
|
||||
raise CallerProjectionError("CALLER_MISMATCH", 403)
|
||||
if scope != SCOPE:
|
||||
raise CallerProjectionError("SCOPE_MISMATCH", 403)
|
||||
body = {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"idempotency_key": self._idempotency_key(caller, scope),
|
||||
"request": dict(request or {}),
|
||||
}
|
||||
connection = _UnixHTTPConnection(self.socket_path, self.timeout)
|
||||
try:
|
||||
connection.request(
|
||||
"POST",
|
||||
"/v1/project-caller",
|
||||
body=json.dumps(body, sort_keys=True, separators=(",", ":")).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Account": caller,
|
||||
},
|
||||
)
|
||||
response = connection.getresponse()
|
||||
status = response.status
|
||||
raw = response.read()
|
||||
credential = response.getheader(CREDENTIAL_HEADER, "")
|
||||
except (OSError, TimeoutError, http.client.HTTPException) as exc:
|
||||
raise CallerProjectionError("PROJECTION_BROKER_UNAVAILABLE", 503) from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise CallerProjectionError("PROJECTION_RESPONSE_INVALID", 503)
|
||||
if status != 200:
|
||||
code = str(value.get("error") or "PROJECTION_DENIED")
|
||||
raise CallerProjectionError(code, 403 if status < 500 else 503)
|
||||
if any(
|
||||
str(key).lower() in {
|
||||
"credential",
|
||||
"credential_material",
|
||||
"token",
|
||||
"bearer",
|
||||
"secret",
|
||||
"password",
|
||||
}
|
||||
for key in value
|
||||
):
|
||||
raise CallerProjectionError("PROJECTION_SECRET_FIELD_REJECTED", 503)
|
||||
if value.get("caller") != CALLER or value.get("scope") != SCOPE:
|
||||
raise CallerProjectionError("PROJECTION_BINDING_INVALID", 503)
|
||||
if value.get("projection_state") != "ACKED":
|
||||
raise CallerProjectionError("PROJECTION_STATE_INVALID", 503)
|
||||
if value.get("credential_state") != "REHYDRATED":
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_INVALID", 503)
|
||||
if value.get("rehydration_state") not in {"GENERATED", "RECOVERED"}:
|
||||
raise CallerProjectionError("REHYDRATION_STATE_INVALID", 503)
|
||||
if value.get("effective_scopes") != [SCOPE]:
|
||||
raise CallerProjectionError("PROJECTION_SCOPE_INVALID", 503)
|
||||
if value.get("secret_exposed") is not False:
|
||||
raise CallerProjectionError("PROJECTION_SECRET_EXPOSURE", 503)
|
||||
if not isinstance(credential, str) or len(credential) < 32:
|
||||
raise CallerProjectionError("CREDENTIAL_REHYDRATION_MISSING", 503)
|
||||
return ProjectionResult(public=dict(value), credential_loaded=True)
|
||||
@@ -12,11 +12,7 @@ BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"projectOnlineAgentExecution",
|
||||
"registerOnlineAgentExecutionPlan",
|
||||
"acquireWriterLease",
|
||||
"bootstrapWriterLease",
|
||||
"renewWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
@@ -34,14 +30,7 @@ BRIDGE_OPERATIONS = (
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"projectOnlineAgentExecution": ("task_id",),
|
||||
"registerOnlineAgentExecutionPlan": (
|
||||
"schema", "task_id", "job_id", "source_head_oid", "generation",
|
||||
"idempotency_key", "execution",
|
||||
),
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"bootstrapWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"renewWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("job_id",),
|
||||
@@ -57,68 +46,6 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"getControlPlaneSnapshot": (),
|
||||
}
|
||||
|
||||
BRIDGE_OPERATION_CONTRACTS = {
|
||||
"projectOnlineAgentExecution": {
|
||||
"method": "POST",
|
||||
"path": "/v2/execution-envelope",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["projectOnlineAgentExecution"],
|
||||
"response_fields": (
|
||||
"schema", "task_id", "job_id", "repository", "base_oid",
|
||||
"expected_branch_oid", "target_branch", "branch_mode", "generation",
|
||||
"idempotency_key", "action", "execution_digest",
|
||||
),
|
||||
},
|
||||
"registerOnlineAgentExecutionPlan": {
|
||||
"method": "POST",
|
||||
"path": "/v2/execution-plan",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["registerOnlineAgentExecutionPlan"],
|
||||
"response_fields": (
|
||||
"schema", "task_id", "job_id", "source_head_oid", "generation",
|
||||
"idempotency_key", "plan_digest", "execution", "execution_digest",
|
||||
),
|
||||
},
|
||||
"acquireWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["acquireWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
),
|
||||
},
|
||||
"bootstrapWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/bootstrap",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"renewWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/renew",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["renewWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"commitFileGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-file",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFileGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
"commitFilesGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-files",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFilesGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
@@ -195,14 +122,6 @@ class BridgeV2Adapter:
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": True,
|
||||
"operations": list(BRIDGE_OPERATIONS),
|
||||
"operation_contracts": {
|
||||
operation: {
|
||||
**contract,
|
||||
"required_fields": list(contract["required_fields"]),
|
||||
"response_fields": list(contract["response_fields"]),
|
||||
}
|
||||
for operation, contract in BRIDGE_OPERATION_CONTRACTS.items()
|
||||
},
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
@@ -231,11 +150,7 @@ class BridgeV2Adapter:
|
||||
return handler(dict(request))
|
||||
|
||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||
def projectOnlineAgentExecution(self, request: Mapping[str, Any]) -> Any: return self._call("projectOnlineAgentExecution", request)
|
||||
def registerOnlineAgentExecutionPlan(self, request: Mapping[str, Any]) -> Any: return self._call("registerOnlineAgentExecutionPlan", request)
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def bootstrapWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("bootstrapWriterLease", request)
|
||||
def renewWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("renewWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,13 +51,11 @@ class AuthorityPort(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_new_exact(self, branch: str, new: str) -> None: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
@@ -105,9 +103,6 @@ class AuthorityGitAdapter:
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str:
|
||||
return self.executor.create_task_branch_CAS(self._ref(branch), expected_base, new)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||
|
||||
@@ -157,7 +152,7 @@ class DetachedGitWorkspace:
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and not dirty.issubset(expected_paths):
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
@@ -263,15 +258,6 @@ class GiteaContinuationRemote:
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_new_exact(self, branch: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) is not None:
|
||||
raise ContinuationExecutionError("GITEA_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
if self.read_git_ref(branch) != new:
|
||||
raise ContinuationExecutionError("GITEA_CREATE_READBACK_MISMATCH")
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
@@ -343,7 +329,6 @@ class ContinuationPermissions:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
job_id: str
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
@@ -404,20 +389,13 @@ class ContinuationExecutor:
|
||||
def observe_generation(self, branch: str, generation: int) -> None:
|
||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||
|
||||
def _max_generation(self, branch: str, *, exclude_job_id: str | None = None) -> int:
|
||||
def _max_generation(self, branch: str) -> int:
|
||||
observed = self._observed_generations.get(branch, 0)
|
||||
with self.controller.store._connect() as connection:
|
||||
if exclude_job_id is None:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases "
|
||||
"WHERE branch=? AND job_id!=?",
|
||||
(branch, exclude_job_id),
|
||||
).fetchone()
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@@ -426,7 +404,6 @@ class ContinuationExecutor:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"job_id": request.job_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
@@ -436,21 +413,10 @@ class ContinuationExecutor:
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> None:
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||
request = binding.request
|
||||
request_mapping = self._request_mapping(request)
|
||||
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
||||
expected_normalized = {
|
||||
**request_mapping,
|
||||
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||
"worktree_mode": (
|
||||
"detached_at_base_oid"
|
||||
if request.branch_mode == "CREATE_NEW"
|
||||
else "detached_at_expected_head"
|
||||
),
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
if normalized != expected_normalized:
|
||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
@@ -460,7 +426,7 @@ class ContinuationExecutor:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch, exclude_job_id=job_id):
|
||||
if request.generation <= self._max_generation(request.target_branch):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
@@ -486,127 +452,57 @@ class ContinuationExecutor:
|
||||
"generation": request.generation,
|
||||
}
|
||||
|
||||
def execute(
|
||||
self,
|
||||
binding: ContinuationRuntimeBinding,
|
||||
commit: ContinuationCommit,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
commit.payload()
|
||||
if not binding.job_id or binding.request.job_id != binding.job_id:
|
||||
raise ContinuationExecutionError("RUNTIME_LINEAGE_MISSING_JOB_ID")
|
||||
current = self.controller.store.read(binding.job_id)
|
||||
current_state = JobState(current["state"])
|
||||
if current_state == JobState.COMPLETED:
|
||||
cycle = self.controller.begin_continuation_cycle(
|
||||
binding.job_id,
|
||||
idempotency_key=commit.idempotency_key,
|
||||
expected_head_oid=binding.request.expected_branch_oid,
|
||||
generation=binding.request.generation,
|
||||
expected_revision=int(current["revision"]),
|
||||
)
|
||||
if cycle.get("completed"):
|
||||
return self.controller.store._receipt(
|
||||
self.controller.store.read(binding.job_id), duplicate=True
|
||||
).as_dict()
|
||||
current = self.controller.store.read(binding.job_id)
|
||||
current_state = JobState(current["state"])
|
||||
if current_state == JobState.REMOTE_VERIFIED:
|
||||
receipt = json.loads(str(current["receipt_json"] or "{}"))
|
||||
commit_oid = str(receipt.get("authority_oid") or "")
|
||||
if not OID_RE.fullmatch(commit_oid):
|
||||
raise ContinuationExecutionError("GOVERNED_COMMIT_OID_INVALID")
|
||||
if before_complete is not None:
|
||||
before_complete(commit_oid)
|
||||
result = self.controller.advance(
|
||||
binding.job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
).as_dict()
|
||||
self.controller.complete_continuation_cycle(
|
||||
binding.job_id,
|
||||
idempotency_key=commit.idempotency_key,
|
||||
commit_oid=commit_oid,
|
||||
)
|
||||
return result
|
||||
self._validate_preflight(binding, job_id=binding.job_id)
|
||||
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||
payload = commit.payload()
|
||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||
current = self.controller.store.read(submitted.job_id)
|
||||
if JobState(current["state"]) == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
self._validate_preflight(binding)
|
||||
request = binding.request
|
||||
projected = False
|
||||
try:
|
||||
self.credential.project()
|
||||
projected = True
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
binding.job_id,
|
||||
submitted.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
binding.job_id,
|
||||
lease_id=f"lease-{binding.job_id}",
|
||||
submitted.job_id,
|
||||
lease_id=f"lease-{submitted.job_id}",
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(binding.job_id, JobState.WORKTREE_READY,
|
||||
side_effect_state="WORKTREE_READY", expected_revision=int(row["revision"]))
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(binding.job_id, JobState.EXECUTING,
|
||||
side_effect_state="IN_PROGRESS", expected_revision=int(row["revision"]))
|
||||
elif state != JobState.EXECUTING:
|
||||
raise ContinuationExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||
if request.branch_mode == "CREATE_NEW":
|
||||
authority_oid = self.authority.create_task_branch_cas(request.target_branch, request.base_oid, new_oid)
|
||||
else:
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
if authority_oid != new_oid:
|
||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
self.controller.advance(
|
||||
binding.job_id,
|
||||
submitted.job_id,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="LOCAL_VERIFIED",
|
||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||
)
|
||||
try:
|
||||
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
if request.branch_mode == "CREATE_NEW":
|
||||
self.remote.push_new_exact(request.target_branch, new_oid)
|
||||
else:
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
except Exception:
|
||||
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
||||
result = self._finish_remote_verification(
|
||||
binding.job_id,
|
||||
authority_oid,
|
||||
new_oid,
|
||||
before_complete=before_complete,
|
||||
)
|
||||
if str(result.get("state") or "") == JobState.COMPLETED.value:
|
||||
self.controller.complete_continuation_cycle(
|
||||
binding.job_id,
|
||||
idempotency_key=commit.idempotency_key,
|
||||
commit_oid=new_oid,
|
||||
)
|
||||
return result
|
||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
@@ -629,14 +525,7 @@ class ContinuationExecutor:
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(
|
||||
self,
|
||||
job_id: str,
|
||||
authority_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
@@ -653,20 +542,8 @@ class ContinuationExecutor:
|
||||
"reconciliation_decision": decision.action.value,
|
||||
"force_push": False,
|
||||
}
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.REMOTE_VERIFIED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
)
|
||||
if before_complete is not None:
|
||||
before_complete(new_oid)
|
||||
return self.controller.advance(
|
||||
job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
).as_dict()
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
|
||||
@@ -49,7 +49,6 @@ class WriterLeaseRequestV2:
|
||||
target_branch: str
|
||||
generation: int
|
||||
lease_seconds: int = 900
|
||||
job_id: str = ""
|
||||
|
||||
schema = "aios.task-writer-lease.v2"
|
||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||
@@ -57,8 +56,6 @@ class WriterLeaseRequestV2:
|
||||
def validate(self, probe: BranchProbe) -> None:
|
||||
if not self.task_id or self.repository != "aios/aios":
|
||||
raise ContinuationError("request_identity_invalid")
|
||||
if not self.job_id or not self.job_id.startswith("job-"):
|
||||
raise ContinuationError("job_id_invalid")
|
||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise ContinuationError("branch_mode_invalid")
|
||||
_oid(self.base_oid, "base_oid")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,19 +27,6 @@ class ServiceUnavailable(JobError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LeaseRenewalResult:
|
||||
"""Durable outcome of one writer-lease recovery attempt."""
|
||||
|
||||
renewed: bool
|
||||
idempotent: bool
|
||||
durable: bool
|
||||
lease_expires_at: str | None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.renewed or self.idempotent
|
||||
|
||||
|
||||
class BindingConflict(JobError):
|
||||
pass
|
||||
|
||||
@@ -79,14 +66,6 @@ def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_utc(value: str) -> datetime:
|
||||
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("LEASE_EXPIRY_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||
@@ -230,15 +209,7 @@ class JobStore:
|
||||
kind TEXT NOT NULL, digest TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS continuation_cycles (
|
||||
job_id TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL, expected_head_oid TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL, state TEXT NOT NULL,
|
||||
commit_oid TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(job_id,idempotency_key), UNIQUE(job_id,sequence),
|
||||
FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||
);
|
||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '2');
|
||||
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
||||
"""
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
@@ -433,16 +404,13 @@ class GitOpsJobController:
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE", lease_expires_at: str | None = None) -> None:
|
||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE") -> None:
|
||||
if len(current_head_oid) != 40 or generation < 1:
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
if lease_expires_at is None:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
else:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,lease_expires_at=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, lease_expires_at, state, lease_id, expected_revision))
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
@@ -451,271 +419,6 @@ class GitOpsJobController:
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def renew_lease(
|
||||
self,
|
||||
*,
|
||||
branch: str,
|
||||
lineage_anchor_oid: str,
|
||||
current_head_oid: str,
|
||||
generation: int,
|
||||
lease_expires_at: str,
|
||||
) -> LeaseRenewalResult:
|
||||
if (
|
||||
not branch
|
||||
or len(lineage_anchor_oid) != 40
|
||||
or len(current_head_oid) != 40
|
||||
or generation < 1
|
||||
or not lease_expires_at.strip()
|
||||
):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT lease_id, revision, generation, state, lease_expires_at "
|
||||
"FROM branch_leases "
|
||||
"WHERE branch=? AND lineage_anchor_oid=? AND current_head_oid=? "
|
||||
"AND generation=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||
(branch, lineage_anchor_oid, current_head_oid, generation),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(False, False, False, None)
|
||||
if len(rows) != 1:
|
||||
raise JobError("LEASE_BINDING_CONFLICT")
|
||||
row = rows[0]
|
||||
try:
|
||||
current_expires_at = parse_utc(str(row["lease_expires_at"]))
|
||||
requested_expires_at = parse_utc(lease_expires_at)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise JobError("LEASE_EXPIRY_INVALID") from exc
|
||||
now = datetime.now(timezone.utc)
|
||||
if requested_expires_at <= now:
|
||||
raise JobError("LEASE_EXPIRY_INVALID")
|
||||
if current_expires_at > now:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(row["lease_expires_at"]),
|
||||
)
|
||||
updated = connection.execute(
|
||||
"UPDATE branch_leases SET lease_expires_at=?,revision=revision+1 "
|
||||
"WHERE lease_id=? AND revision=? AND generation=? "
|
||||
"AND state='ACTIVE' AND lease_expires_at=?",
|
||||
(
|
||||
lease_expires_at,
|
||||
row["lease_id"],
|
||||
int(row["revision"]),
|
||||
generation,
|
||||
row["lease_expires_at"],
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
current = connection.execute(
|
||||
"SELECT lease_expires_at, generation, state FROM branch_leases "
|
||||
"WHERE lease_id=?",
|
||||
(row["lease_id"],),
|
||||
).fetchone()
|
||||
if (
|
||||
current is not None
|
||||
and int(current["generation"]) == generation
|
||||
and current["state"] == "ACTIVE"
|
||||
and parse_utc(str(current["lease_expires_at"])) > now
|
||||
):
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(current["lease_expires_at"]),
|
||||
)
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(True, False, True, lease_expires_at)
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def begin_continuation_cycle(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
expected_head_oid: str,
|
||||
generation: int,
|
||||
expected_revision: int,
|
||||
) -> dict[str, Any]:
|
||||
if (
|
||||
not idempotency_key
|
||||
or len(idempotency_key) > 128
|
||||
or not all(character.isalnum() or character in "._:-" for character in idempotency_key)
|
||||
or len(expected_head_oid) != 40
|
||||
or any(character not in "0123456789abcdef" for character in expected_head_oid)
|
||||
or generation < 1
|
||||
):
|
||||
raise JobError("CONTINUATION_CYCLE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
if row is None:
|
||||
raise JobError("JOB_NOT_FOUND")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM continuation_cycles WHERE job_id=? AND idempotency_key=?",
|
||||
(job_id, idempotency_key),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if str(existing["expected_head_oid"]) != expected_head_oid or int(existing["generation"]) != generation:
|
||||
raise JobError("CONTINUATION_CYCLE_BINDING_CONFLICT")
|
||||
if str(existing["state"]) == "COMPLETED":
|
||||
connection.commit()
|
||||
return {"completed": True, "commit_oid": str(existing["commit_oid"] or ""), "idempotent": True}
|
||||
if str(row["state"]) == JobState.COMPLETED.value:
|
||||
receipt = json.loads(str(row["receipt_json"] or "{}"))
|
||||
commit_oid = str(receipt.get("authority_oid") or "")
|
||||
lease = connection.execute(
|
||||
"SELECT current_head_oid FROM branch_leases WHERE job_id=? AND generation=? AND state='ACTIVE'",
|
||||
(job_id, generation),
|
||||
).fetchall()
|
||||
if (
|
||||
len(lease) == 1
|
||||
and len(commit_oid) == 40
|
||||
and str(lease[0]["current_head_oid"] or "") == commit_oid
|
||||
and receipt.get("remote_verified") is True
|
||||
):
|
||||
now = utc_now()
|
||||
connection.execute(
|
||||
"UPDATE continuation_cycles SET state='COMPLETED',commit_oid=?,updated_at=? "
|
||||
"WHERE job_id=? AND idempotency_key=? AND state='ACTIVE'",
|
||||
(commit_oid, now, job_id, idempotency_key),
|
||||
)
|
||||
connection.commit()
|
||||
return {"completed": True, "commit_oid": commit_oid, "idempotent": True}
|
||||
raise JobError("CONTINUATION_CYCLE_RECOVERY_REQUIRED")
|
||||
connection.commit()
|
||||
return {"completed": False, "commit_oid": None, "idempotent": True}
|
||||
if int(row["revision"]) != expected_revision:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
if str(row["state"]) != JobState.COMPLETED.value:
|
||||
raise JobError("CONTINUATION_CYCLE_JOB_STATE_INVALID")
|
||||
if str(row["branch"]) in {"main", "master"} or str(row["branch"]).startswith(("refs/", "tags/")):
|
||||
raise JobError("PROTECTED_BRANCH_REJECTED")
|
||||
receipt = json.loads(str(row["receipt_json"] or "{}"))
|
||||
if receipt.get("remote_verified") is not True or any(
|
||||
str(receipt.get(field) or "") != expected_head_oid
|
||||
for field in ("authority_oid", "api_branch_oid", "git_remote_oid")
|
||||
):
|
||||
raise JobError("CONTINUATION_CYCLE_RECEIPT_INVALID")
|
||||
leases = connection.execute(
|
||||
"SELECT * FROM branch_leases WHERE job_id=? AND branch=? AND generation=? AND state='ACTIVE'",
|
||||
(job_id, str(row["branch"]), generation),
|
||||
).fetchall()
|
||||
if len(leases) != 1 or str(leases[0]["current_head_oid"] or "") != expected_head_oid:
|
||||
raise JobError("CONTINUATION_CYCLE_LEASE_INVALID")
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM continuation_cycles WHERE job_id=? AND state='ACTIVE' LIMIT 1",
|
||||
(job_id,),
|
||||
).fetchone() is not None:
|
||||
raise JobError("CONTINUATION_CYCLE_ACTIVE_CONFLICT")
|
||||
sequence = int(connection.execute(
|
||||
"SELECT COALESCE(MAX(sequence),0)+1 FROM continuation_cycles WHERE job_id=?",
|
||||
(job_id,),
|
||||
).fetchone()[0])
|
||||
now = utc_now()
|
||||
connection.execute(
|
||||
"INSERT INTO continuation_cycles(job_id,idempotency_key,sequence,expected_head_oid,generation,state,commit_oid,created_at,updated_at) "
|
||||
"VALUES(?,?,?,?,?,'ACTIVE',NULL,?,?)",
|
||||
(job_id, idempotency_key, sequence, expected_head_oid, generation, now, now),
|
||||
)
|
||||
next_revision = expected_revision + 1
|
||||
updated = connection.execute(
|
||||
"UPDATE jobs SET state=?,side_effect_state=?,revision=?,updated_at=? WHERE job_id=? AND revision=?",
|
||||
(JobState.LEASED.value, "CONTINUATION_LEASED", next_revision, now, job_id, expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("REVISION_CAS_CONFLICT")
|
||||
connection.execute(
|
||||
"INSERT INTO state_transitions(job_id,from_state,to_state,revision,side_effect_state,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(job_id, JobState.COMPLETED.value, JobState.LEASED.value, next_revision, "CONTINUATION_LEASED", now),
|
||||
)
|
||||
cycle_receipt = {
|
||||
"idempotency_key": idempotency_key,
|
||||
"expected_head_oid": expected_head_oid,
|
||||
"generation": generation,
|
||||
"sequence": sequence,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO receipts(receipt_id,job_id,kind,digest,payload_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(
|
||||
f"receipt-{job_id}-{next_revision}",
|
||||
job_id,
|
||||
"CONTINUATION_LEASED",
|
||||
hashlib.sha256(canonical_json(cycle_receipt)).hexdigest(),
|
||||
json.dumps(cycle_receipt, sort_keys=True),
|
||||
now,
|
||||
),
|
||||
)
|
||||
result = dict(connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone())
|
||||
connection.commit()
|
||||
except (JobError, BindingConflict):
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
result["request"] = json.loads(result["request_json"])
|
||||
self.store._mirror(result)
|
||||
return {"completed": False, "commit_oid": None, "idempotent": False}
|
||||
|
||||
def complete_continuation_cycle(self, job_id: str, *, idempotency_key: str, commit_oid: str) -> bool:
|
||||
if len(commit_oid) != 40 or any(character not in "0123456789abcdef" for character in commit_oid):
|
||||
raise JobError("CONTINUATION_CYCLE_COMMIT_OID_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
cycle = connection.execute(
|
||||
"SELECT * FROM continuation_cycles WHERE job_id=? AND idempotency_key=?",
|
||||
(job_id, idempotency_key),
|
||||
).fetchone()
|
||||
if cycle is None:
|
||||
connection.commit()
|
||||
return False
|
||||
if str(cycle["state"]) == "COMPLETED":
|
||||
if str(cycle["commit_oid"] or "") != commit_oid:
|
||||
raise JobError("CONTINUATION_CYCLE_COMMIT_CONFLICT")
|
||||
connection.commit()
|
||||
return True
|
||||
row = connection.execute("SELECT * FROM jobs WHERE job_id=?", (job_id,)).fetchone()
|
||||
leases = connection.execute(
|
||||
"SELECT current_head_oid FROM branch_leases WHERE job_id=? AND generation=? AND state='ACTIVE'",
|
||||
(job_id, int(cycle["generation"])),
|
||||
).fetchall()
|
||||
receipt = json.loads(str(row["receipt_json"] or "{}")) if row is not None else {}
|
||||
if (
|
||||
row is None
|
||||
or str(row["state"]) != JobState.COMPLETED.value
|
||||
or receipt.get("remote_verified") is not True
|
||||
or str(receipt.get("authority_oid") or "") != commit_oid
|
||||
or len(leases) != 1
|
||||
or str(leases[0]["current_head_oid"] or "") != commit_oid
|
||||
):
|
||||
raise JobError("CONTINUATION_CYCLE_COMPLETION_EVIDENCE_INVALID")
|
||||
now = utc_now()
|
||||
updated = connection.execute(
|
||||
"UPDATE continuation_cycles SET state='COMPLETED',commit_oid=?,updated_at=? "
|
||||
"WHERE job_id=? AND idempotency_key=? AND state='ACTIVE'",
|
||||
(commit_oid, now, job_id, idempotency_key),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("CONTINUATION_CYCLE_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
return True
|
||||
except (JobError, BindingConflict):
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||
return self._advance(
|
||||
job_id,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -11,11 +9,6 @@ import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
project_online_task,
|
||||
)
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
@@ -43,161 +36,35 @@ class GovernedTaskBinding:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
def _legacy_safe(value: str) -> str:
|
||||
def _safe(value: str) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def _task_key(value: str) -> str:
|
||||
readable = _legacy_safe(value)[:72]
|
||||
return f"{readable}-{hashlib.sha256(value.encode('utf-8')).hexdigest()[:24]}"
|
||||
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True); self.lock_root.mkdir(parents=True, exist_ok=True)
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.task_meta_root.mkdir(parents=True, exist_ok=True); self.job_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
legacy = self.job_root / _legacy_safe(task_id)
|
||||
keyed = self.job_root / _task_key(task_id)
|
||||
root = legacy if legacy.is_dir() and not keyed.exists() else keyed
|
||||
store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_task_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (_legacy_safe(task_id) + ".json")
|
||||
|
||||
@contextmanager
|
||||
def _task_lock(self, task_id: str):
|
||||
path = self.lock_root / (_task_key(task_id) + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@contextmanager
|
||||
def _branch_lock(self, repository: str, target_branch: str):
|
||||
key = hashlib.sha256((repository + "\0" + target_branch).encode("utf-8")).hexdigest()
|
||||
path = self.lock_root / ("branch-" + key + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
return self.task_meta_root / (_safe(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
if not path.exists():
|
||||
path = self._legacy_metadata(task_id)
|
||||
if not path.is_file() or path.is_symlink(): return None
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_KEY_COLLISION")
|
||||
return binding
|
||||
|
||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||
"""Return the exact durable binding; never synthesize missing lineage."""
|
||||
existing = self._read_existing(str(task_id))
|
||||
if existing is None or not existing.job_id:
|
||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||
return existing
|
||||
|
||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||
expected = binding.expected_branch_oid
|
||||
mode = binding.branch_mode
|
||||
if mode == "CREATE_NEW":
|
||||
current = self.authority.read_ref("refs/heads/" + binding.target_branch)
|
||||
if current is not None:
|
||||
store, _ = self._store(binding.task_id)
|
||||
with store._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT current_head_oid,generation,state,lineage_anchor_oid "
|
||||
"FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' "
|
||||
"ORDER BY revision DESC",
|
||||
(binding.job_id, binding.target_branch),
|
||||
).fetchall()
|
||||
if len(rows) != 1:
|
||||
raise BootstrapError("SAME_JOB_CONTINUATION_LEASE_INVALID")
|
||||
row = rows[0]
|
||||
if (
|
||||
str(row["current_head_oid"] or "") != current
|
||||
or int(row["generation"] or 0) != binding.generation
|
||||
or str(row["lineage_anchor_oid"] or "") != binding.base_oid
|
||||
):
|
||||
raise BootstrapError("SAME_JOB_CONTINUATION_BINDING_INVALID")
|
||||
expected = current
|
||||
mode = "CONTINUE_EXISTING"
|
||||
return GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"base_oid": binding.base_oid,
|
||||
"expected_branch_oid": expected,
|
||||
"target_branch": binding.target_branch,
|
||||
"branch_mode": mode,
|
||||
"generation": binding.generation,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
}
|
||||
)
|
||||
|
||||
def execution_envelope(
|
||||
self,
|
||||
task_id: str,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: tuple[str, ...] = (),
|
||||
expected_job_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = self.get_binding(task_id)
|
||||
if expected_job_id and binding.job_id != expected_job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
return project_execution_envelope(
|
||||
self.execution_binding(binding),
|
||||
execution_request,
|
||||
required_capabilities=required_capabilities,
|
||||
)
|
||||
|
||||
def online_task_projection(
|
||||
self,
|
||||
task_id: str,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
expected_job_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = self.get_binding(task_id)
|
||||
if expected_job_id and binding.job_id != expected_job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
return project_online_task(self.execution_binding(binding), task_request, execution_request)
|
||||
return GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||
path = self._metadata(binding.task_id); temporary = path.with_suffix(".tmp"); temporary.write_text(json.dumps(asdict(binding), sort_keys=True), encoding="utf-8"); temporary.replace(path)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = str(request.get("task_id") or "")
|
||||
if not task_id:
|
||||
raise BootstrapError("TASK_BINDING_INVALID")
|
||||
repository = str(request.get("repository") or "")
|
||||
target_branch = str(request.get("target_branch") or "")
|
||||
with self._task_lock(task_id):
|
||||
with self._branch_lock(repository, target_branch):
|
||||
return self._create_locked(request)
|
||||
|
||||
def _create_locked(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
optional = {"expected_branch_oid": existing.expected_branch_oid, "branch_mode": existing.branch_mode, "generation": existing.generation}
|
||||
for key, durable in optional.items():
|
||||
if key in request and str(request[key]) != str(durable): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
return existing.response(idempotent=True)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
@@ -207,24 +74,8 @@ class GovernedTaskBootstrap:
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
parent_task_id = str(request.get("parent_task_id") or "")
|
||||
parent_job_id = str(request.get("parent_job_id") or "")
|
||||
if not parent_task_id or not parent_job_id:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_REQUIRED")
|
||||
parent = self._read_existing(parent_task_id)
|
||||
if parent is None or parent.job_id != parent_job_id or parent.repository != values["repository"] or parent.target_branch != values["target_branch"]:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||
store, _ = self._store(parent_task_id)
|
||||
with store._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT current_head_oid,generation,state FROM branch_leases WHERE job_id=? AND branch=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||
(parent_job_id, values["target_branch"]),
|
||||
).fetchall()
|
||||
if len(rows) != 1 or str(rows[0]["current_head_oid"] or "") != current:
|
||||
raise BootstrapError("EXISTING_BRANCH_OWNERSHIP_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(rows[0]["generation"]) + 1
|
||||
if "generation" in request and int(request["generation"]) != generation:
|
||||
raise BootstrapError("GENERATION_BINDING_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
"""Uncredentialed deterministic executor for governed Online Agent changes.
|
||||
|
||||
This process never receives Agent/Owner/Bridge credentials and never writes Git refs.
|
||||
It operates only on an exchange bundle and returns hash-bound changed files + test evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import errno
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
ALLOWED_PROGRAMS = {"/usr/bin/python3", "/usr/bin/git", "/bin/true", "/usr/bin/true"}
|
||||
MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
|
||||
MAX_FILE_BYTES = 4 * 1024 * 1024
|
||||
SECRET_ENV_TOKENS = ("TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "AUTHORIZATION", "PRIVATE_KEY")
|
||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
||||
SENSITIVE_VALUE_RE = re.compile(
|
||||
r"(?i)(authorization\s*[:=]\s*bearer\s+\S+|authorization\s+bearer\s+\S+|"
|
||||
r"(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+)"
|
||||
)
|
||||
|
||||
|
||||
class IsolatedExecutorError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def safe_relative(value: object) -> str:
|
||||
if not isinstance(value, str) or not value or value.startswith("/"):
|
||||
raise IsolatedExecutorError("PATH_INVALID")
|
||||
path = Path(value)
|
||||
if ".." in path.parts or "." in path.parts or any(part in {"", ".git"} for part in path.parts):
|
||||
raise IsolatedExecutorError("PATH_INVALID")
|
||||
return path.as_posix()
|
||||
|
||||
|
||||
def resolve_under(root: Path, relative: str) -> Path:
|
||||
root = root.resolve()
|
||||
path = (root / safe_relative(relative)).resolve(strict=False)
|
||||
if path == root or root not in path.parents:
|
||||
raise IsolatedExecutorError("PATH_ESCAPE")
|
||||
return path
|
||||
|
||||
|
||||
def _credential_exposure(directory: Path) -> str | None:
|
||||
"""Return an exposure code; EACCES is the intended credential isolation proof."""
|
||||
try:
|
||||
if not directory.exists():
|
||||
return None
|
||||
if directory.is_symlink():
|
||||
return "CREDENTIAL_MOUNT_SYMLINK"
|
||||
if os.access(directory, os.R_OK):
|
||||
return "CREDENTIAL_MOUNT_ACCESSIBLE"
|
||||
if directory.is_dir():
|
||||
for child in directory.iterdir():
|
||||
if child.is_file() and os.access(child, os.R_OK):
|
||||
return "CREDENTIAL_FILE_ACCESSIBLE"
|
||||
except PermissionError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def assert_uncredentialed() -> None:
|
||||
credentials = Path("/run/credentials")
|
||||
candidates = {credentials}
|
||||
credentials_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials"))
|
||||
candidates.add(credentials_directory)
|
||||
leaked = sorted(
|
||||
key for key, value in os.environ.items()
|
||||
if value
|
||||
and (any(token in key.upper() for token in SECRET_ENV_TOKENS) or SENSITIVE_VALUE_RE.search(str(value)))
|
||||
)
|
||||
if leaked:
|
||||
raise IsolatedExecutorError("CREDENTIAL_ENV_EXPOSED:" + ",".join(leaked))
|
||||
for directory in candidates:
|
||||
exposure = _credential_exposure(directory)
|
||||
if exposure is not None:
|
||||
raise IsolatedExecutorError(exposure)
|
||||
# In the intended PrivateNetwork namespace host loopback services are unreachable.
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EACCES, errno.EPERM, errno.EAFNOSUPPORT}:
|
||||
return
|
||||
raise IsolatedExecutorError("NETWORK_ISOLATION_PROBE_FAILED") from exc
|
||||
probe.settimeout(0.15)
|
||||
try:
|
||||
if probe.connect_ex(("127.0.0.1", 18892)) == 0:
|
||||
raise IsolatedExecutorError("GOVERNED_BRIDGE_REACHABLE")
|
||||
finally:
|
||||
probe.close()
|
||||
|
||||
|
||||
def extract_source(archive: Path, root: Path) -> None:
|
||||
if archive.is_symlink() or not archive.is_file() or archive.stat().st_size > MAX_ARCHIVE_BYTES:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_INVALID")
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
for member in tf.getmembers():
|
||||
path = Path(member.name)
|
||||
if path.is_absolute() or ".." in path.parts or ".git" in path.parts:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||
if member.issym() or member.islnk():
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_LINK_DENY")
|
||||
tf.extractall(root, filter="data")
|
||||
|
||||
|
||||
def run_command(item: Mapping[str, Any], root: Path) -> dict[str, Any]:
|
||||
argv = item.get("argv")
|
||||
if not isinstance(argv, Sequence) or isinstance(argv, (str, bytes)) or not argv:
|
||||
raise IsolatedExecutorError("COMMAND_INVALID")
|
||||
argv = [str(part) for part in argv]
|
||||
if argv[0] not in ALLOWED_PROGRAMS:
|
||||
raise IsolatedExecutorError("COMMAND_PROGRAM_DENY")
|
||||
if argv[0] == "/usr/bin/python3" and "-m" in argv:
|
||||
index = argv.index("-m")
|
||||
if index + 1 >= len(argv) or argv[index + 1] not in {"pytest", "py_compile"}:
|
||||
raise IsolatedExecutorError("PYTHON_MODULE_DENY")
|
||||
if argv[0] == "/usr/bin/git" and any(
|
||||
token in {"push", "update-ref", "commit", "merge", "rebase", "reset", "clean", "branch"}
|
||||
for token in argv[1:]
|
||||
):
|
||||
raise IsolatedExecutorError("GIT_MUTATION_DENY")
|
||||
timeout = int(item.get("timeout_seconds") or 300)
|
||||
if not 1 <= timeout <= 1800:
|
||||
raise IsolatedExecutorError("COMMAND_TIMEOUT_INVALID")
|
||||
cwd = root if item.get("cwd", ".") == "." else resolve_under(root, str(item.get("cwd")))
|
||||
started = time.monotonic()
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8"},
|
||||
)
|
||||
result = {
|
||||
"label": str(item.get("label") or "command"),
|
||||
"exit_code": completed.returncode,
|
||||
"stdout_sha256": sha256(completed.stdout.encode("utf-8", "replace")),
|
||||
"stderr_sha256": sha256(completed.stderr.encode("utf-8", "replace")),
|
||||
"duration_ms": int((time.monotonic() - started) * 1000),
|
||||
}
|
||||
if completed.returncode != 0:
|
||||
raise IsolatedExecutorError(
|
||||
"COMMAND_FAILED:" + result["label"] + ":" + str(completed.returncode)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def apply_changes(request: Mapping[str, Any], root: Path) -> list[str]:
|
||||
changes = request.get("change_set")
|
||||
if not isinstance(changes, list) or not changes:
|
||||
raise IsolatedExecutorError("CHANGE_SET_INVALID")
|
||||
changed: list[str] = []
|
||||
for change in changes:
|
||||
if not isinstance(change, Mapping):
|
||||
raise IsolatedExecutorError("CHANGE_INVALID")
|
||||
relative = safe_relative(change.get("path"))
|
||||
path = resolve_under(root, relative)
|
||||
current = path.read_bytes() if path.is_file() else None
|
||||
expected = change.get("expected_sha256")
|
||||
if expected is not None and (current is None or sha256(current) != expected):
|
||||
raise IsolatedExecutorError("SOURCE_DRIFT:" + relative)
|
||||
operation = change.get("operation")
|
||||
if operation == "write_file":
|
||||
content = change.get("content")
|
||||
if not isinstance(content, str):
|
||||
raise IsolatedExecutorError("WRITE_CONTENT_INVALID")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
elif operation == "replace_text":
|
||||
if current is None:
|
||||
raise IsolatedExecutorError("REPLACE_SOURCE_MISSING")
|
||||
old = str(change.get("old"))
|
||||
new = str(change.get("new"))
|
||||
count = int(change.get("count", 1))
|
||||
text = current.decode("utf-8")
|
||||
if text.count(old) != count:
|
||||
raise IsolatedExecutorError("REPLACE_COUNT_INVALID:" + relative)
|
||||
path.write_text(text.replace(old, new), encoding="utf-8")
|
||||
elif operation == "delete_file":
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
else:
|
||||
raise IsolatedExecutorError("CHANGE_OPERATION_INVALID")
|
||||
changed.append(relative)
|
||||
return sorted(set(changed))
|
||||
|
||||
|
||||
def changed_bundle(root: Path, changed: list[str]) -> list[dict[str, Any]]:
|
||||
files = []
|
||||
for relative in changed:
|
||||
path = resolve_under(root, relative)
|
||||
if path.exists():
|
||||
data = path.read_bytes()
|
||||
if len(data) > MAX_FILE_BYTES:
|
||||
raise IsolatedExecutorError("CHANGED_FILE_TOO_LARGE:" + relative)
|
||||
files.append({
|
||||
"path": relative,
|
||||
"deleted": False,
|
||||
"sha256": sha256(data),
|
||||
"content_b64": base64.b64encode(data).decode("ascii"),
|
||||
})
|
||||
else:
|
||||
files.append({"path": relative, "deleted": True, "sha256": None, "content_b64": None})
|
||||
return files
|
||||
|
||||
|
||||
def execute_request(request_path: Path, result_path: Path) -> None:
|
||||
assert_uncredentialed()
|
||||
request = json.loads(request_path.read_text(encoding="utf-8"))
|
||||
if request.get("schema") != "aios.isolated-execution.request.v1":
|
||||
raise IsolatedExecutorError("REQUEST_SCHEMA_INVALID")
|
||||
request_id = request.get("request_id")
|
||||
if not isinstance(request_id, str) or not REQUEST_ID_RE.fullmatch(request_id):
|
||||
raise IsolatedExecutorError("REQUEST_ID_INVALID")
|
||||
source_archive = Path(str(request.get("source_archive") or ""))
|
||||
if not source_archive.is_absolute():
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_PATH_INVALID")
|
||||
expected_archive_sha = str(request.get("source_archive_sha256") or "")
|
||||
if sha256(source_archive.read_bytes()) != expected_archive_sha:
|
||||
raise IsolatedExecutorError("SOURCE_ARCHIVE_SHA_MISMATCH")
|
||||
with tempfile.TemporaryDirectory(prefix="aios-isolated-executor-") as temporary:
|
||||
root = Path(temporary) / "workspace"
|
||||
root.mkdir()
|
||||
extract_source(source_archive, root)
|
||||
changed = apply_changes(request, root)
|
||||
commands = [
|
||||
run_command(item, root)
|
||||
for item in request.get("verification", [])
|
||||
if isinstance(item, Mapping)
|
||||
]
|
||||
result = {
|
||||
"schema": "aios.isolated-execution.result.v1",
|
||||
"request_id": request_id,
|
||||
"request_sha256": sha256(canonical(request)),
|
||||
"changed_files": changed_bundle(root, changed),
|
||||
"verification": commands,
|
||||
"credential_isolation": {
|
||||
"credential_env_absent": True,
|
||||
"credential_mount_inaccessible": True,
|
||||
"governed_bridge_unreachable": True,
|
||||
},
|
||||
}
|
||||
result["result_sha256"] = sha256(canonical(result))
|
||||
temporary_result = result_path.with_suffix(".tmp")
|
||||
temporary_result.write_text(json.dumps(result, sort_keys=True, separators=(",", ":")), encoding="utf-8")
|
||||
os.replace(temporary_result, result_path)
|
||||
|
||||
|
||||
def serve(root: Path) -> None:
|
||||
assert_uncredentialed()
|
||||
requests = root / "requests"
|
||||
results = root / "results"
|
||||
failed = root / "failed"
|
||||
for path in (requests, results, failed):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
while True:
|
||||
for request in sorted(requests.glob("*.json")):
|
||||
if request.is_symlink():
|
||||
continue
|
||||
result = results / request.name
|
||||
try:
|
||||
execute_request(request, result)
|
||||
request.unlink()
|
||||
except Exception as exc:
|
||||
(failed / request.name).write_text(
|
||||
json.dumps({"error": str(exc) or type(exc).__name__}, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
request.unlink(missing_ok=True)
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", default="/var/lib/aios-online-agent-executor")
|
||||
parser.add_argument("--request")
|
||||
parser.add_argument("--result")
|
||||
args = parser.parse_args()
|
||||
if args.request and args.result:
|
||||
execute_request(Path(args.request), Path(args.result))
|
||||
else:
|
||||
serve(Path(args.root))
|
||||
@@ -3,6 +3,13 @@ from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from aios_caller_admission_projection import (
|
||||
CALLER,
|
||||
SCOPE,
|
||||
CallerProjectionClient,
|
||||
CallerProjectionError,
|
||||
)
|
||||
from baseline_authority import BaselineAuthority, BaselineAuthorityError
|
||||
from typing import Any
|
||||
|
||||
@@ -89,6 +96,11 @@ SILENT_RELEASE_PUBLIC_KEYS = Path(os.getenv(
|
||||
'AIOS_SILENT_RELEASE_PUBLIC_KEYS',
|
||||
'/etc/aios-gitops/silent-release-owner-public-keys.json',
|
||||
))
|
||||
CALLER_PROJECTION_SOCKET = Path(os.getenv(
|
||||
'AIOS_OWNER_AUTH_BROKER_SOCKET',
|
||||
'/run/aios-owner-authorization-broker/broker.sock',
|
||||
))
|
||||
CALLER_PROJECTION_CLIENT = CallerProjectionClient(CALLER_PROJECTION_SOCKET)
|
||||
monitor = ActionMonitor()
|
||||
|
||||
BASE_MODELS = [
|
||||
@@ -112,6 +124,9 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/governed-write-capabilities', '/api/control-plane-snapshot',
|
||||
'/api/caller-capabilities',
|
||||
'/api/online-agent-task', '/api/online-agent-task-events',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -407,14 +422,113 @@ class H(BaseHTTPRequestHandler):
|
||||
except Exception: return {'_raw':raw}
|
||||
def actor(self):
|
||||
auth=self.headers.get('Authorization',''); tok=auth[7:].strip() if auth.startswith('Bearer ') else self.headers.get('X-AIOS-Token','')
|
||||
rec=authenticate(tok)
|
||||
if rec: return {'ok':True,'account':safe(rec.get('account') or rec.get('name') or 'token-user'),'role':rec.get('role','user'),'scopes':set(rec.get('scopes') or [])}
|
||||
return {'ok':False,'account':'anonymous','role':'anonymous','scopes':set()}
|
||||
def need(self, scope):
|
||||
a=self.actor()
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
try:
|
||||
rec=authenticate(tok)
|
||||
except Exception:
|
||||
rec=None
|
||||
if not isinstance(rec, dict) or not rec:
|
||||
return {
|
||||
'ok': False,
|
||||
'account': 'anonymous',
|
||||
'role': 'anonymous',
|
||||
'scopes': set(),
|
||||
'static_scopes': set(),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
scopes = set(rec.get('scopes') or [])
|
||||
return {
|
||||
'ok': True,
|
||||
'account': safe(rec.get('account') or rec.get('name') or 'token-user'),
|
||||
'role': rec.get('role', 'user'),
|
||||
'scopes': scopes,
|
||||
'static_scopes': set(scopes),
|
||||
'projection': None,
|
||||
'credential_loaded': False,
|
||||
}
|
||||
def need(self, scope, *, projection_request=None):
|
||||
a = self.actor()
|
||||
if not a['ok']:
|
||||
self.sendj({'error': 'unauthorized', 'hint': 'Bearer token required'}, 401)
|
||||
return None
|
||||
if a['account'] == CALLER and scope == SCOPE:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
a['account'],
|
||||
scope,
|
||||
projection_request,
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'caller_projection_denied',
|
||||
'projection_state': 'DENIED',
|
||||
'detail': exc.code,
|
||||
'account': a['account'],
|
||||
},
|
||||
exc.status,
|
||||
)
|
||||
return None
|
||||
projection = result.public
|
||||
a['scopes'].update(projection.get('effective_scopes') or [])
|
||||
a['projection'] = projection
|
||||
a['credential_loaded'] = result.credential_loaded
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']:
|
||||
self.sendj(
|
||||
{
|
||||
'error': 'forbidden',
|
||||
'required_scope': scope,
|
||||
'account': a['account'],
|
||||
},
|
||||
403,
|
||||
)
|
||||
return None
|
||||
return a
|
||||
def caller_capabilities(self):
|
||||
actor = self.actor()
|
||||
if not actor['ok']:
|
||||
return self.sendj(
|
||||
{'error': 'unauthorized', 'hint': 'Bearer token required'},
|
||||
401,
|
||||
)
|
||||
effective = set(actor['static_scopes'])
|
||||
projection_state = 'NOT_REQUIRED'
|
||||
credential_state = 'NOT_REQUIRED'
|
||||
rehydration_state = 'NOT_APPLICABLE'
|
||||
admitted = True
|
||||
projection_error = None
|
||||
if actor['account'] == CALLER:
|
||||
try:
|
||||
result = CALLER_PROJECTION_CLIENT.rehydrate(
|
||||
actor['account'],
|
||||
SCOPE,
|
||||
{},
|
||||
)
|
||||
except CallerProjectionError as exc:
|
||||
admitted = False
|
||||
projection_state = 'DENIED'
|
||||
credential_state = 'NOT_REHYDRATED'
|
||||
rehydration_state = 'FAILED'
|
||||
projection_error = exc.code
|
||||
else:
|
||||
effective.update(result.public.get('effective_scopes') or [])
|
||||
projection_state = result.public['projection_state']
|
||||
credential_state = result.public['credential_state']
|
||||
rehydration_state = result.public['rehydration_state']
|
||||
return self.sendj(
|
||||
{
|
||||
'schema': 'aios.caller-capability-introspection.v1',
|
||||
'caller_identity': actor['account'],
|
||||
'admitted': admitted,
|
||||
'effective_scopes': sorted(effective),
|
||||
'projection_state': projection_state,
|
||||
'credential_state': credential_state,
|
||||
'rehydration_state': rehydration_state,
|
||||
'projection_error': projection_error,
|
||||
'secret_exposed': False,
|
||||
}
|
||||
)
|
||||
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
@@ -446,24 +560,183 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def bridge_v2_json(self, route, data=None, method='POST'):
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=payload,
|
||||
headers={'Content-Type': 'application/json'} if payload is not None else {},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def control_plane_snapshot(self):
|
||||
"""Build a non-sensitive snapshot locally when Bridge v2 has no snapshot HTTP route."""
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
|
||||
def bridge_get(route):
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
headers={'Accept': 'application/json'},
|
||||
method='GET',
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError('bridge_read_http_' + str(response.status))
|
||||
value = json.loads(response.read().decode('utf-8'))
|
||||
if not isinstance(value, dict):
|
||||
raise RuntimeError('bridge_read_not_object')
|
||||
return value
|
||||
|
||||
try:
|
||||
runtime = bridge_get('/v2/runtime')
|
||||
capabilities = bridge_get('/v2/capabilities')
|
||||
repository = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
repository_identity=REPOSITORY_IDENTITY,
|
||||
)
|
||||
if not repository.get('repository_available'):
|
||||
return self.sendj({
|
||||
'error': 'AUTHORITATIVE_REPOSITORY_UNAVAILABLE',
|
||||
'query_error': repository.get('query_error'),
|
||||
}, 503)
|
||||
baseline = dict(BASELINE_AUTHORITY.read())
|
||||
except BaselineAuthorityError as exc:
|
||||
return self.sendj({
|
||||
'error': 'RECONCILIATION_REQUIRED',
|
||||
'detail': str(exc),
|
||||
}, 503)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': 'HTTP_' + str(exc.code),
|
||||
}, 503)
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError, RuntimeError, json.JSONDecodeError) as exc:
|
||||
return self.sendj({
|
||||
'error': 'GOVERNED_BRIDGE_UNAVAILABLE',
|
||||
'detail': type(exc).__name__,
|
||||
}, 503)
|
||||
|
||||
legacy = capabilities.get('legacy_writes_enabled')
|
||||
if legacy is None:
|
||||
legacy = capabilities.get('legacy_global_writes_enabled')
|
||||
payload = {
|
||||
'schema': 'aios.control-plane-snapshot.aggregate.v1',
|
||||
'snapshot_source': 'model-api-local-aggregate',
|
||||
'version': {
|
||||
'target_release': runtime.get('control_plane_release_version'),
|
||||
},
|
||||
'runtime': {
|
||||
'bridge_schema_version': runtime.get('bridge_schema_version'),
|
||||
'control_plane_release_version': runtime.get('control_plane_release_version'),
|
||||
'runtime_release_commit': runtime.get('runtime_release_commit'),
|
||||
'runtime_path': runtime.get('runtime_path'),
|
||||
'artifact_sha256': runtime.get('artifact_sha256'),
|
||||
'drift_status': runtime.get('drift_status'),
|
||||
'deployment_state': runtime.get('deployment_state'),
|
||||
},
|
||||
'repository': {
|
||||
'identity': runtime.get('repository_identity') or runtime.get('repository') or REPOSITORY_IDENTITY,
|
||||
'authority_git_dir': runtime.get('authoritative_git_dir') or repository.get('authoritative_git_dir'),
|
||||
'authority_main_oid': repository.get('authoritative_head_oid'),
|
||||
},
|
||||
'production': {
|
||||
'baseline_version': baseline.get('current_baseline'),
|
||||
'target_oid': baseline.get('target_oid'),
|
||||
'deployment_state': baseline.get('status'),
|
||||
'baseline_generation': baseline.get('generation'),
|
||||
'baseline_sha256': baseline.get('authority_sha256'),
|
||||
},
|
||||
'task': {},
|
||||
'capabilities': {
|
||||
'legacy_writes_enabled': legacy,
|
||||
'governed_writes_enabled': capabilities.get('governed_writes_enabled'),
|
||||
'force_push': capabilities.get('force_push'),
|
||||
'main_write': capabilities.get('main_write'),
|
||||
'tag_write': capabilities.get('tag_write'),
|
||||
'baseline_write': capabilities.get('baseline_write'),
|
||||
'production_write': capabilities.get('production_write'),
|
||||
},
|
||||
'credential_broker': {
|
||||
'status': 'SERVER_SIDE_ONLY',
|
||||
'provider': 'BridgeV2RuntimeCredentialProjection',
|
||||
},
|
||||
'secret_material_exposed': False,
|
||||
}
|
||||
return self.sendj(payload)
|
||||
|
||||
def agent_task_control(self, method, route, data=None):
|
||||
token_path = Path(os.getenv(
|
||||
'AIOS_AGENT_TASK_CONTROL_TOKEN_FILE',
|
||||
'/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token',
|
||||
))
|
||||
if token_path.is_symlink() or not token_path.is_file():
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_UNAVAILABLE'}, 503)
|
||||
token = token_path.read_text(encoding='utf-8').strip()
|
||||
if len(token) < 32:
|
||||
return self.sendj({'error': 'AGENT_TASK_CONTROL_CREDENTIAL_INVALID'}, 503)
|
||||
base = os.getenv('AIOS_AGENT_TASK_API_URL', 'http://127.0.0.1:18940').rstrip('/')
|
||||
payload = None if data is None else json.dumps(
|
||||
data, sort_keys=True, separators=(',', ':')
|
||||
).encode('utf-8')
|
||||
headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'}
|
||||
if payload is not None:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
request = urllib.request.Request(
|
||||
base + route, data=payload, headers=headers, method=method
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'agent_task_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj(
|
||||
{'error': 'AGENT_TASK_API_UNAVAILABLE', 'detail': type(exc).__name__},
|
||||
503,
|
||||
)
|
||||
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if path == '/api/governed-write-capabilities':
|
||||
return self.bridge_v2_json('/v2/capabilities', method='GET')
|
||||
if path == '/api/control-plane-snapshot':
|
||||
return self.control_plane_snapshot()
|
||||
if path == '/api/caller-capabilities':
|
||||
return self.caller_capabilities()
|
||||
if path in ('/api/online-agent-task', '/api/online-agent-task-events'):
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
task_id = (q.get('task_id') or [''])[0]
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/events' if path.endswith('-events') else ''
|
||||
return self.agent_task_control('GET', '/v1/tasks/' + task_id + suffix)
|
||||
try:
|
||||
if path in ('/health','/api/health'):
|
||||
return self.sendj({
|
||||
@@ -700,12 +973,58 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
governed_routes = {
|
||||
'/createGovernedTask': ('/v2/governed-task', 'branch:create'),
|
||||
'/acquireWriterLease': ('/v2/writer-lease', 'file:write'),
|
||||
'/commitFileGoverned': ('/v2/commit-file', 'file:write'),
|
||||
'/commitFilesGoverned': ('/v2/commit-files', 'file:write'),
|
||||
'/getGitOpsJob': ('/v2/job', 'file:write'),
|
||||
'/resumeGitOpsJob': ('/v2/job/resume', 'file:write'),
|
||||
'/reconcileGitOpsJob': ('/v2/job/reconcile', 'file:write'),
|
||||
'/createTagApprovalRequest': ('/v2/tag/approval-request', 'tag:create'),
|
||||
'/requestOrCreateTag': ('/v2/tag/publish', 'tag:create'),
|
||||
'/getTagPublicationStatus': ('/v2/tag/status', 'tag:create'),
|
||||
'/buildProductionArtifact': ('/v2/release/artifact', 'tag:create'),
|
||||
'/executeApprovedProductionRelease': ('/v2/release/execute', 'tag:create'),
|
||||
'/getReleaseConsistency': ('/v2/release/consistency', 'tag:create'),
|
||||
'/runReleaseAcceptance': ('/v2/release/acceptance', 'tag:create'),
|
||||
}
|
||||
if path in governed_routes:
|
||||
route, scope = governed_routes[path]
|
||||
actor = self.need(scope)
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_v2_json(route, self.body(), method='POST')
|
||||
if path in {
|
||||
'/createOnlineAgentTask', '/registerOnlineAgent',
|
||||
'/verifyOnlineAgentTask', '/cancelOnlineAgentTask',
|
||||
}:
|
||||
actor = self.need('agent-task:control')
|
||||
if not actor:
|
||||
return
|
||||
data = self.body()
|
||||
if path == '/createOnlineAgentTask':
|
||||
return self.agent_task_control('POST', '/v1/tasks', data)
|
||||
if path == '/registerOnlineAgent':
|
||||
return self.agent_task_control('POST', '/v1/agents/register', data)
|
||||
task_id = str(data.pop('task_id', '') or '')
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{2,179}', task_id):
|
||||
return self.sendj({'error': 'task_id_invalid'}, 400)
|
||||
suffix = '/verify' if path == '/verifyOnlineAgentTask' else '/cancel'
|
||||
return self.agent_task_control('POST', '/v1/tasks/' + task_id + suffix, data)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
if path == '/createReleaseCandidate':
|
||||
a=self.need('release:candidate')
|
||||
if not a: return
|
||||
a = self.need(
|
||||
'release:candidate',
|
||||
projection_request={
|
||||
'source_commit': str(data.get('source_commit') or ''),
|
||||
'version': str(data.get('version') or ''),
|
||||
},
|
||||
)
|
||||
if not a:
|
||||
return
|
||||
return self.sendj(
|
||||
silent_release_service().create_release_candidate(data)
|
||||
)
|
||||
@@ -996,7 +1315,7 @@ def api_spec():
|
||||
return {
|
||||
'version': 'V2R2C1-IMMUTABLE-STAGING',
|
||||
'openapi': '/api/openapi.yaml',
|
||||
'read': ['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'read': ['GET /api/caller-capabilities','GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],
|
||||
'write': ['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],
|
||||
'policy': {
|
||||
'bearer_required_for_write': True,
|
||||
@@ -1011,6 +1330,9 @@ def api_spec():
|
||||
'silent_release_shadow_default': True,
|
||||
'runtime_mode': 'IMMUTABLE_STAGING',
|
||||
'governed_release_routing': 'bridge-v2',
|
||||
'caller_admission_projection': 'durable-owner-policy-server-side-ack',
|
||||
'caller_projection_scope': SCOPE,
|
||||
'caller_projection_caller': CALLER,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
"""Canonical governed execution projection for Online Agent Tasks.
|
||||
|
||||
The durable governed binding is authoritative. Natural-language task fields and
|
||||
worker configuration are never allowed to invent repository, ref, generation, or
|
||||
idempotency coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture"})
|
||||
|
||||
|
||||
class ExecutionProjectionError(RuntimeError):
|
||||
"""A canonical fail-closed projection or validation error."""
|
||||
|
||||
|
||||
class OnlineTaskProjectionClient:
|
||||
"""Official HTTP projection client using only a control credential reference."""
|
||||
|
||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.control_token_path = Path(control_token_path)
|
||||
|
||||
def _token(self) -> str:
|
||||
path = self.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||
return value
|
||||
|
||||
def create(
|
||||
self,
|
||||
projected_task: Mapping[str, Any],
|
||||
*,
|
||||
binding: "GovernedExecutionBinding",
|
||||
) -> dict[str, Any]:
|
||||
body = dict(projected_task)
|
||||
payload = _canonical(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.api_base + "/v1/tasks",
|
||||
method="POST",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._token(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(8192).decode("utf-8", "replace")
|
||||
try:
|
||||
error = json.loads(raw).get("error")
|
||||
except (AttributeError, json.JSONDecodeError):
|
||||
error = "HTTP_ERROR"
|
||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
if status not in {200, 201} or not isinstance(result, dict):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
metadata = result.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
returned_execution = validate_execution_envelope(
|
||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||
submitted_execution = validate_execution_envelope(
|
||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _error(code: str) -> None:
|
||||
raise ExecutionProjectionError(code)
|
||||
|
||||
|
||||
def _object(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error(code)
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _string(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
_error(code)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _task_id(value: object) -> str:
|
||||
value = _string(value, "TASK_ID_INVALID")
|
||||
if TASK_RE.fullmatch(value) is None:
|
||||
_error("TASK_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, code: str) -> str:
|
||||
value = _string(value, code)
|
||||
if OID_RE.fullmatch(value) is None:
|
||||
_error(code)
|
||||
return value
|
||||
|
||||
|
||||
def _branch(value: object) -> str:
|
||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||
_error("TARGET_BRANCH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _generation(value: object) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
_error("GENERATION_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _source_signals(value: object) -> tuple[str, ...]:
|
||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
text = " ".join(item.split()).strip()
|
||||
if not text:
|
||||
continue
|
||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||
text = text[:512].rstrip()
|
||||
if text not in result:
|
||||
result.append(text)
|
||||
if len(result) == 16:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _capabilities(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||
if any(len(item) > 120 for item in result):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedExecutionBinding:
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||
data = dict(value)
|
||||
task_id = _task_id(data.get("task_id"))
|
||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||
if TASK_RE.fullmatch(job_id) is None:
|
||||
_error("JOB_ID_INVALID")
|
||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||
target = _branch(data.get("target_branch"))
|
||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
_error("BRANCH_MODE_INVALID")
|
||||
generation = _generation(data.get("generation"))
|
||||
if mode == "CREATE_NEW" and expected != base_oid:
|
||||
_error("EXPECTED_BASE_CONFLICT")
|
||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(key) is None:
|
||||
_error("IDEMPOTENCY_KEY_INVALID")
|
||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(value)
|
||||
|
||||
|
||||
def validate_execution_envelope(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
binding: GovernedExecutionBinding | None = None,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
envelope = dict(value)
|
||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||
if binding is not None and candidate != binding:
|
||||
comparisons = (
|
||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual, expected, code in comparisons:
|
||||
if actual != expected:
|
||||
_error(code)
|
||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||
_error("CAPABILITY_MISMATCH_DENY")
|
||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||
_error("WORKSPACE_POLICY_DENY")
|
||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||
governed_coordinates = (
|
||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||
if actual_coordinate != expected_coordinate:
|
||||
_error(code)
|
||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(commit_key) is None:
|
||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||
_error("WRITER_LEASE_POLICY_DENY")
|
||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||
if governance.get(field, False) is not False:
|
||||
_error("POLICY_DENY")
|
||||
changes = envelope.get("change_set")
|
||||
verification = envelope.get("verification")
|
||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||
_error("EXECUTION_OPERATIONS_INVALID")
|
||||
if action == "source_capture":
|
||||
if changes or verification:
|
||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||
if envelope.get("source_signals") != signals:
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
elif not changes:
|
||||
_error("CHANGE_SET_INVALID")
|
||||
declared = str(envelope.get("execution_digest") or "")
|
||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||
actual = _digest(material)
|
||||
if declared != actual:
|
||||
_error("EXECUTION_DIGEST_INVALID")
|
||||
return {**material, "execution_digest": actual}
|
||||
|
||||
|
||||
def project_execution_envelope(
|
||||
binding: GovernedExecutionBinding,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
request = dict(execution_request or {})
|
||||
action = str(request.get("action") or "source_capture")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||
governance = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||
"writer_lease": action in MUTATION_ACTIONS,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"force_push": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
supplied_governance = request.get("governance")
|
||||
if supplied_governance is not None:
|
||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||
material: dict[str, Any] = {
|
||||
"schema": EXECUTION_SCHEMA,
|
||||
**binding.public(),
|
||||
"action": action,
|
||||
"adapter": str(request.get("adapter") or ("deterministic" if action == "deterministic" else "source_capture")),
|
||||
"capability_scope": list(capabilities),
|
||||
"change_set": list(request.get("change_set") or []),
|
||||
"verification": list(request.get("verification") or []),
|
||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||
"governance": governance,
|
||||
"workspace": {
|
||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||
"read_only_repository": True,
|
||||
"cleanup_policy": "always",
|
||||
},
|
||||
}
|
||||
envelope = {**material, "execution_digest": _digest(material)}
|
||||
return validate_execution_envelope(envelope, binding=binding)
|
||||
|
||||
|
||||
def project_online_task(
|
||||
binding: GovernedExecutionBinding,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = dict(task_request)
|
||||
required = _capabilities(request.get("required_capabilities"))
|
||||
execution = project_execution_envelope(binding, execution_request, required_capabilities=required)
|
||||
metadata = dict(request.get("metadata") or {})
|
||||
if "execution" in metadata:
|
||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||
if metadata["execution"] != execution:
|
||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||
metadata["execution"] = execution
|
||||
metadata["execution_projection"] = {
|
||||
"schema": PROJECTION_SCHEMA,
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
}
|
||||
return {
|
||||
**request,
|
||||
"task_id": binding.task_id,
|
||||
"metadata": metadata,
|
||||
"required_capabilities": list(required),
|
||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_task_execution(
|
||||
task: Mapping[str, Any],
|
||||
binding: GovernedExecutionBinding,
|
||||
*,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
metadata = task.get("metadata")
|
||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||
value = validate_execution_envelope(
|
||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||
binding=binding,
|
||||
worker_capabilities=worker_capabilities,
|
||||
)
|
||||
return value, "persisted"
|
||||
projected = project_execution_envelope(
|
||||
binding,
|
||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||
required_capabilities=task.get("required_capabilities") or [],
|
||||
)
|
||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||
return projected, "recovered_projection"
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Durable, authority-bound deterministic execution plans for Online Agent Tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_online_agent_execution import (
|
||||
GovernedExecutionBinding,
|
||||
project_execution_envelope,
|
||||
)
|
||||
|
||||
|
||||
class ExecutionPlanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _task_key(task_id: str) -> str:
|
||||
readable = "".join(ch if ch.isalnum() or ch in "._-" else "-" for ch in task_id).strip("-")[:72] or "task"
|
||||
return readable + "-" + hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPlanRecord:
|
||||
task_id: str
|
||||
job_id: str
|
||||
source_head_oid: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
plan_digest: str
|
||||
execution: dict[str, Any]
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": "aios.online-agent.plan-record.v1",
|
||||
"task_id": self.task_id,
|
||||
"job_id": self.job_id,
|
||||
"source_head_oid": self.source_head_oid,
|
||||
"generation": self.generation,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"plan_digest": self.plan_digest,
|
||||
"execution": dict(self.execution),
|
||||
}
|
||||
|
||||
|
||||
class ExecutionPlanStore:
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, task_id: str) -> Path:
|
||||
return self.root / (_task_key(task_id) + ".json")
|
||||
|
||||
@contextmanager
|
||||
def _lock(self, task_id: str):
|
||||
path = self.root / (_task_key(task_id) + ".lock")
|
||||
with path.open("a+b") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
@staticmethod
|
||||
def _source_head(binding: GovernedExecutionBinding) -> str:
|
||||
return binding.expected_branch_oid if binding.branch_mode == "CONTINUE_EXISTING" else binding.base_oid
|
||||
|
||||
def _read(self, task_id: str) -> ExecutionPlanRecord | None:
|
||||
path = self._path(task_id)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return None
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping) or value.get("schema") != "aios.online-agent.plan-record.v1":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||
execution = value.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_RECORD_INVALID")
|
||||
return ExecutionPlanRecord(
|
||||
task_id=str(value.get("task_id") or ""),
|
||||
job_id=str(value.get("job_id") or ""),
|
||||
source_head_oid=str(value.get("source_head_oid") or ""),
|
||||
generation=int(value.get("generation") or 0),
|
||||
idempotency_key=str(value.get("idempotency_key") or ""),
|
||||
plan_digest=str(value.get("plan_digest") or ""),
|
||||
execution=dict(execution),
|
||||
)
|
||||
|
||||
def register(
|
||||
self,
|
||||
binding: GovernedExecutionBinding,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
with self._lock(binding.task_id):
|
||||
return self._register_locked(binding, request)
|
||||
|
||||
def _register_locked(
|
||||
self,
|
||||
binding: GovernedExecutionBinding,
|
||||
request: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if request.get("schema") != "aios.online-agent.plan.v1":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SCHEMA_INVALID")
|
||||
if request.get("task_id") != binding.task_id or request.get("job_id") != binding.job_id:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||
if int(request.get("generation") or 0) != binding.generation:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_GENERATION_CONFLICT")
|
||||
source_head = str(request.get("source_head_oid") or "")
|
||||
if source_head != self._source_head(binding):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_CONFLICT")
|
||||
key = str(request.get("idempotency_key") or "")
|
||||
if len(key) < 8:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_INVALID")
|
||||
execution_request = request.get("execution")
|
||||
if not isinstance(execution_request, Mapping) or execution_request.get("action") != "deterministic":
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_DETERMINISTIC_REQUIRED")
|
||||
envelope = project_execution_envelope(binding, execution_request)
|
||||
material = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"source_head_oid": source_head,
|
||||
"generation": binding.generation,
|
||||
"idempotency_key": key,
|
||||
"execution": dict(execution_request),
|
||||
}
|
||||
plan_digest = _digest(material)
|
||||
existing = self._read(binding.task_id)
|
||||
expected_previous = request.get("expected_previous_plan_digest")
|
||||
if existing is not None:
|
||||
if existing.idempotency_key == key:
|
||||
if existing.plan_digest != plan_digest:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_IDEMPOTENCY_CONFLICT")
|
||||
return {**existing.public(), "idempotent": True, "execution_digest": envelope["execution_digest"]}
|
||||
if expected_previous != existing.plan_digest:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||
if existing.source_head_oid != source_head:
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_STALE_REPLACEMENT_DENY")
|
||||
elif expected_previous not in (None, ""):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_REPLACEMENT_CAS_MISMATCH")
|
||||
|
||||
record = ExecutionPlanRecord(
|
||||
task_id=binding.task_id,
|
||||
job_id=binding.job_id,
|
||||
source_head_oid=source_head,
|
||||
generation=binding.generation,
|
||||
idempotency_key=key,
|
||||
plan_digest=plan_digest,
|
||||
execution=dict(execution_request),
|
||||
)
|
||||
path = self._path(binding.task_id)
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(record.public(), sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
return {**record.public(), "idempotent": False, "execution_digest": envelope["execution_digest"]}
|
||||
|
||||
def resolve(self, binding: GovernedExecutionBinding) -> dict[str, Any] | None:
|
||||
record = self._read(binding.task_id)
|
||||
if record is None:
|
||||
return None
|
||||
if (
|
||||
record.task_id != binding.task_id
|
||||
or record.job_id != binding.job_id
|
||||
or record.generation != binding.generation
|
||||
):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_BINDING_CONFLICT")
|
||||
if record.source_head_oid != self._source_head(binding):
|
||||
raise ExecutionPlanError("EXECUTION_PLAN_SOURCE_HEAD_DRIFT")
|
||||
envelope = project_execution_envelope(binding, record.execution)
|
||||
return {
|
||||
"execution": dict(record.execution),
|
||||
"plan_digest": record.plan_digest,
|
||||
"execution_digest": envelope["execution_digest"],
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -964,7 +964,6 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
required = {
|
||||
"schema",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"repository",
|
||||
"branch_mode",
|
||||
"base_oid",
|
||||
@@ -977,7 +976,6 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||
request = WriterLeaseRequestV2(
|
||||
task_id=str(raw["task_id"]),
|
||||
job_id=str(raw["job_id"]),
|
||||
repository=str(raw["repository"]),
|
||||
branch_mode=str(raw["branch_mode"]),
|
||||
base_oid=str(raw["base_oid"]),
|
||||
@@ -990,7 +988,6 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"job_id": request.job_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# External agent ingress. TLS termination is assumed on the enclosing server.
|
||||
# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)
|
||||
# are intentionally NOT exposed here; the AIOS control plane reaches them on
|
||||
# loopback with its server-side credential.
|
||||
|
||||
location = /agent-tasks/health {
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /agent-tasks/v1/tasks/poll {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}/(claim|heartbeat|result)$ {
|
||||
limit_except POST { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 1m;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
|
||||
location ~ ^/agent-tasks/v1/tasks/[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$ {
|
||||
limit_except GET { deny all; }
|
||||
rewrite ^/agent-tasks/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:18940;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 35s;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=AIOS Online Agent Task API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-agent-task
|
||||
Group=aios-agent-task
|
||||
WorkingDirectory=@RUNTIME_ROOT@
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app
|
||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
||||
Environment=AIOS_AGENT_TASK_HOST=127.0.0.1
|
||||
Environment=AIOS_AGENT_TASK_PORT=18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-agent-task-api.service/control_token
|
||||
LoadCredential=control_token:/etc/aios-gitops/agent-task-control.token
|
||||
ExecStart=/usr/bin/python3 @RUNTIME_ROOT@/app/aios_agent_task_api.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
ReadWritePaths=/var/lib/aios-agent-task
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,5 +0,0 @@
|
||||
[Service]
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_ONLINE_TASK_PROJECTION_ENABLED=1
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
@@ -0,0 +1,7 @@
|
||||
# Existing model API drop-in. Install only with the governed runtime package.
|
||||
[Service]
|
||||
SupplementaryGroups=gitops aios-executor-exchange aios-autonomous-agent-worker
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=/run/aios-owner-authorization-broker/broker.sock
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_CALLER=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SCOPE=release:candidate
|
||||
@@ -28,6 +28,9 @@ Environment=AIOS_GITOPS_MODEL_API_SERVICE=aios-gitops-model-api-v2r2c1-staging.s
|
||||
Environment=AIOS_SILENT_RELEASE_EXECUTION_ENABLED=false
|
||||
Environment=AIOS_SILENT_RELEASE_STATE_ROOT=/var/lib/aios-gitops/model-api-staging/silent-release
|
||||
Environment=AIOS_BRIDGE_V2_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=/run/credentials/aios-gitops-model-api-v2r2c1-staging.service/agent_task_control_token
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_GITOPS_HOST=127.0.0.1
|
||||
Environment=AIOS_GITOPS_PORT=18893
|
||||
Environment=PYTHONPATH=@RUNTIME_ROOT@/app:@RUNTIME_ROOT@/runtime-agent-os
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS uncredentialed deterministic Planner/Executor
|
||||
After=local-fs.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-engineering-executor
|
||||
Group=aios-executor-exchange
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_isolated_executor.py --root /var/lib/aios-online-agent-executor
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateNetwork=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=localhost
|
||||
InaccessiblePaths=/run/credentials
|
||||
ReadWritePaths=/var/lib/aios-online-agent-executor
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,45 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS governed Online Agent Worker
|
||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-engineering-worker
|
||||
Group=aios-engineering-worker
|
||||
SupplementaryGroups=gitops aios-executor-exchange
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_ID=online-engineering-worker
|
||||
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-17.1-dynamic
|
||||
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker
|
||||
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker/workspaces
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-online-agent-worker
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,45 +0,0 @@
|
||||
[Unit]
|
||||
Description=AIOS trusted dynamic engineering consumer
|
||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=aios-engineering-worker
|
||||
Group=aios-engineering-worker
|
||||
SupplementaryGroups=gitops aios-executor-exchange
|
||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-engineering-20260815/agent_token
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||
Environment=AIOS_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||
Environment=AIOS_AGENT_ID=online-engineering-consumer
|
||||
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||
Environment=AIOS_AGENT_VERSION=2026-08-17.1-dynamic
|
||||
Environment=AIOS_WORKER_CAPABILITIES=git.read,git.test,governed.write.branch,governed.write.file
|
||||
Environment=AIOS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
Environment=AIOS_ISOLATED_EXECUTOR_ROOT=/var/lib/aios-online-agent-executor
|
||||
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-engineering-consumer
|
||||
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-engineering-consumer/workspaces
|
||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=20
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
LockPersonality=true
|
||||
RestrictSUIDSGID=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
MemoryDenyWriteExecute=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||
ReadWritePaths=/var/lib/aios-online-engineering-consumer
|
||||
UMask=0027
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,49 @@
|
||||
[Unit]
|
||||
Description=AIOS durable Owner authorization broker
|
||||
After=network.target aios-chatgpt-bridge-v2-staging.service
|
||||
Wants=aios-chatgpt-bridge-v2-staging.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=aios-executor-exchange
|
||||
RuntimeDirectory=aios-owner-authorization-broker
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=aios-owner-authorization-broker
|
||||
StateDirectoryMode=0750
|
||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_POLICY=/etc/aios-gitops/owner-durable-authorization.json
|
||||
Environment=AIOS_OWNER_DURABLE_AUTHORIZATION_REVOCATIONS=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ACCOUNT=chatgpt-governed-implementer-singlehost
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_ALLOWED_USERS=aios-autonomous-agent-worker,aiman
|
||||
Environment=AIOS_OWNER_AUTH_BROKER_SOCKET=%t/aios-owner-authorization-broker/broker.sock
|
||||
ExecStart=/usr/bin/python3 -B @RUNTIME_ROOT@/active-runtime/app/aios_durable_owner_authorization.py
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectClock=yes
|
||||
LockPersonality=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET
|
||||
IPAddressDeny=any
|
||||
IPAddressAllow=127.0.0.0/8
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization.json
|
||||
ReadOnlyPaths=/etc/aios-gitops/owner-durable-authorization-revocations.json
|
||||
ReadOnlyPaths=@RUNTIME_ROOT@
|
||||
InaccessiblePaths=/data/aios-gitops /home
|
||||
UMask=0007
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,4 +0,0 @@
|
||||
g aios-executor-exchange -
|
||||
u aios-engineering-executor - "AIOS isolated engineering executor" /var/lib/aios-online-agent-executor /usr/sbin/nologin
|
||||
m aios-engineering-executor aios-executor-exchange
|
||||
m aios-engineering-worker aios-executor-exchange
|
||||
@@ -1,5 +0,0 @@
|
||||
d /var/lib/aios-online-agent-executor 2770 aios-engineering-executor aios-executor-exchange -
|
||||
d /var/lib/aios-online-agent-executor/requests 2770 aios-engineering-worker aios-executor-exchange -
|
||||
d /var/lib/aios-online-agent-executor/results 2770 aios-engineering-executor aios-executor-exchange -
|
||||
d /var/lib/aios-online-agent-executor/failed 2770 aios-engineering-executor aios-executor-exchange -
|
||||
d /var/lib/aios-online-agent-executor/sources 2770 aios-engineering-worker aios-executor-exchange -
|
||||
@@ -0,0 +1,88 @@
|
||||
# Caller admission projection and rehydration
|
||||
|
||||
This runbook covers the exact durable Owner policy and the exact API caller:
|
||||
|
||||
- caller: \`chatgpt-governed-implementer-singlehost\`
|
||||
- scope: \`release:candidate\`
|
||||
- policy source: \`/etc/aios-gitops/owner-durable-authorization.json\`
|
||||
- revocation source: \`/etc/aios-gitops/owner-durable-authorization-revocations.json\`
|
||||
|
||||
## Admission source
|
||||
|
||||
The model API first authenticates the existing caller token from the static
|
||||
registry. For this exact caller and exact release scope, static authentication
|
||||
is not sufficient. The model API calls the existing Unix-socket Owner broker.
|
||||
The broker reloads and validates the durable policy on every projection request,
|
||||
including account, capability, optional expiry, and revocation.
|
||||
|
||||
Other callers retain their existing static-scope behavior. The target caller
|
||||
does not receive a permanent \`release:candidate\` entry in the static registry,
|
||||
and it is never treated as a release-worker account.
|
||||
|
||||
## Projection lifecycle and ACK semantics
|
||||
|
||||
The broker records only non-secret projection metadata in its existing durable
|
||||
state database. The state sequence is:
|
||||
|
||||
\`PENDING\` -> \`ACKED\`
|
||||
|
||||
The ACK is produced by the broker's server-side projection consumer after the
|
||||
exact policy and request binding have passed validation. The public response
|
||||
contains projection state, effective scope, policy digest, expiry and
|
||||
rehydration state. The short-lived credential material is returned only in a
|
||||
private Unix-socket response header to the model API process. It is not written
|
||||
to the response body, logs, diagnostics, Git, task metadata or terminal output.
|
||||
|
||||
## Credential rehydration, expiry and revocation
|
||||
|
||||
The credential projection is generated in memory for a short TTL and is
|
||||
discarded after the model API admission check. The durable row stores no raw
|
||||
credential and no bearer token. After a broker or model API restart, the next
|
||||
admission request creates a new short-lived credential from the durable
|
||||
projection metadata; this is reported as \`RECOVERED\`.
|
||||
|
||||
Every request reloads the policy. Missing, malformed, expired or revoked policy
|
||||
fails closed. A caller mismatch, scope mismatch, invalid projection state,
|
||||
broker outage, missing credential header or unexpected secret field also fails
|
||||
closed. No static scope fallback is allowed for the target caller.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Use the read-only API with the caller's existing credential:
|
||||
|
||||
\`GET /api/caller-capabilities\`
|
||||
|
||||
The response is safe to display and contains:
|
||||
|
||||
- \`caller_identity\`
|
||||
- \`admitted\`
|
||||
- \`effective_scopes\`
|
||||
- \`projection_state\`
|
||||
- \`credential_state\`
|
||||
- \`rehydration_state\`
|
||||
- \`secret_exposed=false\`
|
||||
|
||||
It never contains a bearer, token, credential value or credential hash.
|
||||
|
||||
For a release candidate request, the model API also binds the exact
|
||||
\`source_commit\` and \`version\` to the durable policy before invoking the
|
||||
trusted release service. The caller cannot supply release evidence fields.
|
||||
|
||||
## Failure states and troubleshooting
|
||||
|
||||
- \`PROJECTION_BROKER_UNAVAILABLE\`: verify the broker unit, socket path and
|
||||
the model API supplementary groups; do not add a static scope.
|
||||
- \`OWNER_GRANT_REVOKED\` or \`POLICY_EXPIRED\`: restore a valid Owner policy
|
||||
through governed policy management; do not edit broker state.
|
||||
- \`CALLER_MISMATCH\` or \`SCOPE_MISMATCH\`: verify the authenticated account and
|
||||
requested exact scope.
|
||||
- \`PROJECTION_STATE_INVALID\`: inspect the durable projection through the
|
||||
broker's read-only diagnostics and fail closed until it is reconciled.
|
||||
- \`CREDENTIAL_REHYDRATION_MISSING\`: restart only the related control-plane
|
||||
service after capturing pre-state and then repeat the read-only diagnosis.
|
||||
- \`trusted candidate builder is not configured\`: Gate A admission is working,
|
||||
but the separate existing TrustedCandidateBuilder runtime must be restored
|
||||
before Gate B can create a candidate.
|
||||
|
||||
No Owner Approval, Deployment, Host Seed, Host Activation, Projector,
|
||||
continuation task, Tag or Baseline action is part of this path.
|
||||
@@ -0,0 +1,295 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS Online Agent Task API
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Durable online task distribution for OpenClaw, Hermes, and other external
|
||||
agents. Agent credentials and lease tokens are bearer credentials and must
|
||||
never be committed to Git.
|
||||
servers:
|
||||
- url: /agent-tasks
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
operationId: agentTaskHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service health
|
||||
/v1/agents/register:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only. Returns the agent token once.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RegisterAgentRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Registered
|
||||
/v1/tasks:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateTaskRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Task created
|
||||
/v1/tasks/poll:
|
||||
post:
|
||||
operationId: pollOnlineAgentTasks
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Agent operation. Returns capability-compatible queued tasks.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
"200":
|
||||
description: Available tasks
|
||||
/v1/tasks/{task_id}:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/v1/tasks/{task_id}/claim:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: claimOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
responses:
|
||||
"200":
|
||||
description: >
|
||||
Claim succeeded. The lease_token is returned once and must be kept
|
||||
in runtime memory/secret storage, never Git.
|
||||
"409":
|
||||
description: Already claimed or otherwise not claimable
|
||||
/v1/tasks/{task_id}/heartbeat:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: heartbeatOnlineAgentTask
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HeartbeatRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Lease renewed
|
||||
/v1/tasks/{task_id}/result:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: submitOnlineAgentResult
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ResultRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Result accepted into VERIFYING
|
||||
/v1/tasks/{task_id}/verify:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: verifyOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VerifyRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Verification decision applied
|
||||
/v1/tasks/{task_id}/cancel:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
post:
|
||||
operationId: cancelOnlineAgentTaskDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Cancelled or already terminal
|
||||
/v1/tasks/{task_id}/events:
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/TaskId"
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEventsDirect
|
||||
security:
|
||||
- bearerAuth: []
|
||||
description: Control-plane only.
|
||||
responses:
|
||||
"200":
|
||||
description: Audit event list
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
parameters:
|
||||
TaskId:
|
||||
name: task_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$"
|
||||
schemas:
|
||||
RegisterAgentRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
agent_type:
|
||||
type: string
|
||||
examples: [openclaw]
|
||||
agent_version:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
CreateTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- goal
|
||||
- instructions
|
||||
- success_criteria
|
||||
- idempotency_key
|
||||
properties:
|
||||
task_id:
|
||||
type: string
|
||||
goal:
|
||||
type: string
|
||||
instructions:
|
||||
type: string
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline:
|
||||
type: string
|
||||
format: date-time
|
||||
priority:
|
||||
type: integer
|
||||
minimum: -100
|
||||
maximum: 100
|
||||
default: 0
|
||||
max_attempts:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 20
|
||||
default: 3
|
||||
idempotency_key:
|
||||
type: string
|
||||
HeartbeatRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
lease_seconds:
|
||||
type: integer
|
||||
minimum: 30
|
||||
maximum: 3600
|
||||
default: 300
|
||||
progress:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
ResultRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [lease_token, idempotency_key, result]
|
||||
properties:
|
||||
lease_token:
|
||||
type: string
|
||||
idempotency_key:
|
||||
type: string
|
||||
result:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
VerifyRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [decision]
|
||||
properties:
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: AIOS ChatGPT Governed Write and Agent Task Extension
|
||||
version: 1.0.0
|
||||
description: >
|
||||
Merge these paths and schemas into docs/gitops/openapi-chatgpt-actions.yaml.
|
||||
This extension deliberately exposes governed Bridge v2 operations rather
|
||||
than reopening legacy AIOS_GITOPS_WRITES_ENABLED.
|
||||
paths:
|
||||
/api/governed-write-capabilities:
|
||||
get:
|
||||
operationId: getGovernedWriteCapabilities
|
||||
responses:
|
||||
"200":
|
||||
description: Governed and legacy write capability status
|
||||
/api/control-plane-snapshot:
|
||||
get:
|
||||
operationId: getControlPlaneSnapshot
|
||||
responses:
|
||||
"200":
|
||||
description: Non-sensitive control-plane snapshot
|
||||
/createGovernedTask:
|
||||
post:
|
||||
operationId: createGovernedTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateGovernedTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Governed task binding
|
||||
/acquireWriterLease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WriterLeaseRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Writer lease binding
|
||||
/commitFileGoverned:
|
||||
post:
|
||||
operationId: commitFileGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFileGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/commitFilesGoverned:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CommitFilesGovernedRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Durable GitOps job receipt
|
||||
/getGitOpsJob:
|
||||
post:
|
||||
operationId: getGitOpsJob
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Job receipt
|
||||
/resumeGitOpsJob:
|
||||
post:
|
||||
operationId: resumeGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Resumed job receipt
|
||||
/reconcileGitOpsJob:
|
||||
post:
|
||||
operationId: reconcileGitOpsJob
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [job_id, expected_revision]
|
||||
properties:
|
||||
job_id: {type: string}
|
||||
expected_revision: {type: integer}
|
||||
responses:
|
||||
"200":
|
||||
description: Reconciliation receipt
|
||||
/createOnlineAgentTask:
|
||||
post:
|
||||
operationId: createOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateOnlineAgentTaskRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Online task created
|
||||
"201":
|
||||
description: Online task created
|
||||
/registerOnlineAgent:
|
||||
post:
|
||||
operationId: registerOnlineAgent
|
||||
x-openai-isConsequential: true
|
||||
description: >
|
||||
Control operation. The returned agent token is secret material and must
|
||||
be delivered through runtime secret storage, never Git.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [agent_id, agent_type, agent_version, capabilities]
|
||||
properties:
|
||||
agent_id: {type: string}
|
||||
agent_type: {type: string}
|
||||
agent_version: {type: string}
|
||||
capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"201":
|
||||
description: Agent registered
|
||||
/api/online-agent-task:
|
||||
get:
|
||||
operationId: getOnlineAgentTask
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task status
|
||||
/api/online-agent-task-events:
|
||||
get:
|
||||
operationId: getOnlineAgentTaskEvents
|
||||
parameters:
|
||||
- name: task_id
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task events
|
||||
/verifyOnlineAgentTask:
|
||||
post:
|
||||
operationId: verifyOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id, decision]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
decision:
|
||||
type: string
|
||||
enum: [SUCCEEDED, NEEDS_REWORK, FAILED]
|
||||
verification:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
description: Verification applied
|
||||
/cancelOnlineAgentTask:
|
||||
post:
|
||||
operationId: cancelOnlineAgentTask
|
||||
x-openai-isConsequential: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [task_id]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
reason: {type: string}
|
||||
responses:
|
||||
"200":
|
||||
description: Task cancelled
|
||||
components:
|
||||
schemas:
|
||||
Oid40:
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{40}$"
|
||||
CreateGovernedTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, base_oid, target_branch, risk, description, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch:
|
||||
type: string
|
||||
pattern: "^agent/"
|
||||
risk:
|
||||
type: string
|
||||
enum: [low, medium, high, engineering]
|
||||
description: {type: string}
|
||||
idempotency_key: {type: string}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
WriterLeaseRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[schema, task_id, repository, branch_mode, base_oid, expected_branch_oid,
|
||||
target_branch, generation, lease_seconds]
|
||||
properties:
|
||||
schema: {type: string}
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode:
|
||||
type: string
|
||||
enum: [CREATE_NEW, CONTINUE_EXISTING]
|
||||
base_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
CommitFileGovernedRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, path, content_base64, message]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
path: {type: string}
|
||||
content_base64: {type: string}
|
||||
message: {type: string}
|
||||
CommitFilesGovernedRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
[task_id, repository, expected_branch_oid, target_branch, generation,
|
||||
idempotency_key, files, message]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {$ref: "#/components/schemas/Oid40"}
|
||||
target_branch: {type: string, pattern: "^agent/"}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string}
|
||||
files:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Base64 encoded file bytes
|
||||
message: {type: string}
|
||||
CreateOnlineAgentTaskRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [goal, instructions, success_criteria, idempotency_key]
|
||||
properties:
|
||||
task_id: {type: string}
|
||||
goal: {type: string}
|
||||
instructions: {type: string}
|
||||
success_criteria:
|
||||
type: array
|
||||
items: {}
|
||||
required_capabilities:
|
||||
type: array
|
||||
items: {type: string}
|
||||
constraints:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
deadline: {type: string, format: date-time}
|
||||
priority: {type: integer, minimum: -100, maximum: 100}
|
||||
max_attempts: {type: integer, minimum: 1, maximum: 20}
|
||||
idempotency_key: {type: string}
|
||||
@@ -1,134 +0,0 @@
|
||||
# Online Agent Automatic Claim Infrastructure
|
||||
|
||||
## Status and fixed boundary
|
||||
|
||||
This candidate targets `p0-claim-online-agent-auto-execution-20260817` on governed Job
|
||||
`job-2031a9e5682a4238f98e3ac7aee8a593`, base
|
||||
`a642b045775b9154a00ae78543037fbef4fb447b`, target branch
|
||||
`agent/p0-claim-online-agent-auto-execution-20260817`, generation 1.
|
||||
|
||||
It does not authorize protected-main, Production, tag, baseline, raw Git, Task DB, or
|
||||
credential mutations.
|
||||
|
||||
## Considered approaches
|
||||
|
||||
1. Require every caller to write `metadata.execution`. This is simple but preserves the
|
||||
current operational gap: a valid governed Job can still project an unexecutable Online
|
||||
Task.
|
||||
2. Let workers infer repository and WriterLease coordinates from natural-language Task
|
||||
fields. This removes the skip but violates fail-closed lineage and can bind the wrong Job,
|
||||
branch, base, or generation.
|
||||
3. Use a canonical server-side projector plus a task-agnostic worker resolver. This is the
|
||||
selected approach. The governed binding remains the only authority for repository, Job,
|
||||
branch, base, expected head, generation, and idempotency. Producers can persist the
|
||||
projected envelope when creating the Online Task. A worker may recover an omitted envelope
|
||||
only from the same canonical projector and durable binding; it never guesses coordinates.
|
||||
|
||||
## Components
|
||||
|
||||
### Canonical execution contract
|
||||
|
||||
`aios_online_agent_execution.py` owns schema `aios.online-agent.execution.v2`. It validates
|
||||
the complete governed lineage and supports two explicit actions:
|
||||
|
||||
- `deterministic`: a hash-bound change set, verification commands, and governed commit;
|
||||
- `source_capture`: a read-only first attempt that returns `NEEDS_REWORK_SOURCE_CAPTURE` and
|
||||
cannot acquire WriterLease or mutate Git.
|
||||
|
||||
Wrong task, Job, repository, base, expected head, branch, generation, capability scope, or
|
||||
governance policy fails closed with a canonical diagnostic.
|
||||
|
||||
### Governed Job to Online Task projection
|
||||
|
||||
`GovernedTaskBootstrap` exposes an exact durable binding lookup and builds Online Task
|
||||
projection data through the canonical contract. When the Bridge is configured with the
|
||||
official Agent Task control credential reference, governed Job creation submits that complete
|
||||
projection through the official Task HTTP API. The Bridge also exposes a loopback envelope
|
||||
projection operation used for recovery. The persisted projection includes the versioned
|
||||
envelope rather than relying on manual metadata edits.
|
||||
|
||||
Projection delivery is a durable outbox, not a best-effort tail call. The outbox record is
|
||||
written before HTTP delivery and a Bridge reconciler retries it after transient API failure or
|
||||
process restart. A response is accepted only when its complete execution envelope equals the
|
||||
submitted projection. Requests that explicitly require projection are rejected before governed
|
||||
bootstrap when the projection principal is unavailable.
|
||||
|
||||
Loopback is not treated as an identity boundary. Every POST route requires an exact
|
||||
runtime-principal bearer credential supplied by systemd credential projection; unauthenticated
|
||||
local callers are denied before request dispatch. The control-plane credential used to create
|
||||
Online Tasks remains a separate Bridge-only reference and is never projected into the worker.
|
||||
|
||||
For legacy Online Tasks with an omitted envelope, the generic worker asks the same Bridge
|
||||
operation for a read-only `source_capture` envelope. Projection succeeds only when the exact
|
||||
durable binding exists. An omitted envelope is therefore recoverable; an invalid envelope is
|
||||
never replaced or weakened.
|
||||
|
||||
Persisted envelopes are also reprojected through the Bridge before claim and compared by exact
|
||||
digest and Job lineage. A self-consistent client-generated envelope is therefore not authority.
|
||||
|
||||
### Dynamic worker and trusted consumer
|
||||
|
||||
The repository gains a deployable generic worker and systemd unit definitions as governed
|
||||
source files. The worker validates or
|
||||
resolves an envelope before claim, revalidates after atomic claim, starts heartbeat, and then
|
||||
executes the selected action. The trusted engineering consumer uses this same task-agnostic
|
||||
entrypoint. No compiled worker contains a Task ID, base OID, branch, generation, or plan path.
|
||||
|
||||
Skip observability distinguishes:
|
||||
|
||||
- `TASK_SKIPPED_PROJECTION_MISSING`;
|
||||
- `TASK_SKIPPED_ENVELOPE_INVALID`;
|
||||
- `TASK_SKIPPED_CAPABILITY_MISMATCH`;
|
||||
- `TASK_SKIPPED_POLICY_DENY`;
|
||||
- `TASK_POLL_FAILED` / worker unavailable.
|
||||
|
||||
## State flow
|
||||
|
||||
```text
|
||||
Governed Job BOUND
|
||||
-> canonical durable binding
|
||||
-> Online Task projection with execution.v2
|
||||
-> capability-filtered poll
|
||||
-> envelope and lineage validation
|
||||
-> atomic claim
|
||||
-> initial heartbeat/start
|
||||
-> refetch Task and re-resolve envelope
|
||||
-> compare execution digest and Job
|
||||
-> source capture OR deterministic governed execution
|
||||
-> result construction
|
||||
-> result submission
|
||||
-> external server-side verification gate
|
||||
```
|
||||
|
||||
Legacy omission uses `binding -> source_capture projection` between poll and validation. It
|
||||
does not write Task metadata and does not convert a malformed envelope into a valid one.
|
||||
|
||||
## Recovery and safety
|
||||
|
||||
- Projection is deterministic and digest-bound.
|
||||
- Governed Task creation is serialized by a cross-process, Task-ID-digest lock. Durable paths
|
||||
use collision-resistant exact Task keys while retaining read compatibility with verified
|
||||
legacy bindings.
|
||||
- A new Task cannot adopt an existing branch from caller-selected generation data. It must name
|
||||
an exact durable parent Task/Job whose single ACTIVE lease owns the branch head; generation is
|
||||
then derived from that lease.
|
||||
- Claim is attempted only after validation; claimed payload drift is denied.
|
||||
- Source capture has no WriterLease or commit path.
|
||||
- Deterministic execution retains detached worktree, exact WriterLease, governed commit,
|
||||
durable current-head CAS, and receipt convergence.
|
||||
- Local deterministic recovery is keyed by Task and execution digest; live acceptance must
|
||||
independently reconverge Job, lease, receipt, and branch OIDs before verification.
|
||||
- An ambiguous commit transport or server outcome becomes `RECONCILIATION_REQUIRED`; the
|
||||
worker does not submit a canonical failure until exact Job receipt and head evidence is
|
||||
observed.
|
||||
- A completed read-only `source_capture` state is ignored when a later attempt has a changed
|
||||
digest and no governed state exists; all other digest drift is denied.
|
||||
- Protected refs are never worker targets.
|
||||
|
||||
## Acceptance
|
||||
|
||||
The implementation must pass the named projection, schema, claimability, dynamic-binding,
|
||||
wrong-lineage, capability, P0-A, and P0-B tests. Runtime acceptance additionally requires a
|
||||
real Online Task to be claimed and heartbeated without per-task worker or systemd plan
|
||||
injection, plus governed receipt/OID/CAS convergence for this implementation Task.
|
||||
Unit tests and deployable unit definitions do not by themselves satisfy runtime acceptance.
|
||||
@@ -1,56 +0,0 @@
|
||||
# P0-CLAIM Scheme A follow-up
|
||||
|
||||
This follow-up continues the existing candidate `71523aef6e48b1e2fa516c456ca189b7a039104b`.
|
||||
It does not create a replacement Task, Job, branch or generation.
|
||||
|
||||
## Retry extension
|
||||
|
||||
A versioned Owner-controlled loopback API provides
|
||||
`POST /v1/tasks/{task_id}/retry-extension`.
|
||||
|
||||
The request binds FAILED status, revision, exhausted attempt/max_attempts, next max attempts,
|
||||
previous verification SHA256, Owner authorization ID and idempotency key. The service performs one
|
||||
SQLite `BEGIN IMMEDIATE` transaction containing the CAS update, durable retry-extension record and
|
||||
Task event. Each authorization extends the boundary by exactly one attempt.
|
||||
|
||||
## Same-Job continuation
|
||||
|
||||
The durable GovernedTask binding remains the lineage anchor. After the first CREATE_NEW commit,
|
||||
`execution_binding()` may normalize that SAME Job to `CONTINUE_EXISTING` only when exactly one
|
||||
ACTIVE WriterLease for the Job owns the current authority branch head, generation and lineage
|
||||
anchor. The lineage `base_oid` stays unchanged; only `expected_branch_oid` becomes the current head.
|
||||
|
||||
GovernedTask creation is also serialized by a branch-digest lock, preventing sibling continuation
|
||||
tasks from racing generation admission.
|
||||
|
||||
## Projection and recovery
|
||||
|
||||
Natural-language source signals are normalized server-side to at most 16 unique 512-byte values.
|
||||
Malformed persisted envelopes still fail closed.
|
||||
|
||||
Fresh commits and recovery require a COMPLETED Job, `remote_verified=true`, exact
|
||||
authority/API/remote receipt OIDs, and exactly one ACTIVE WriterLease whose durable current head
|
||||
matches the commit. Delayed local ref visibility becomes `RECONCILIATION_REQUIRED`, not a terminal
|
||||
canonical failure.
|
||||
|
||||
## Credential isolation
|
||||
|
||||
The credentialed Coordinator performs only Task/Bridge coordination and governed commit. All
|
||||
deterministic repository modification and test commands run in
|
||||
`aios-online-agent-isolated-executor` under a separate system user with:
|
||||
|
||||
- no Agent/Owner credential projection;
|
||||
- no gitops supplementary group;
|
||||
- `PrivateNetwork=true`;
|
||||
- `RestrictAddressFamilies=AF_UNIX AF_INET`, with `IPAddressDeny=any` and
|
||||
`IPAddressAllow=localhost`, so regression tests may use an isolated loopback
|
||||
HTTP server without gaining host or external network reachability;
|
||||
- `InaccessiblePaths=/run/credentials`.
|
||||
|
||||
The executor receives a hash-bound source archive and deterministic change set through a filesystem
|
||||
exchange and returns only changed file bytes/hashes plus command evidence. The Coordinator validates
|
||||
that response before materializing exact allowed files and calling the governed Bridge.
|
||||
|
||||
Codex/Hermes mutation adapters remain disabled until a separately audited uncredentialed planner
|
||||
backend exists. Under the current operating model, implementation code is authored before Online
|
||||
Agent execution and is carried as a deterministic plan.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"execution_id": "exec_d75446452723",
|
||||
"user_id": "user1",
|
||||
"visible_scope": "group1",
|
||||
"entry_agent": "openclaw",
|
||||
"path": [
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"codex"
|
||||
],
|
||||
"used_hermes": true,
|
||||
"closed_by": "codex",
|
||||
"duration_ms": 0,
|
||||
"input_tokens": 100,
|
||||
"output_tokens": 200,
|
||||
"total_tokens": 300,
|
||||
"status": "completed",
|
||||
"started_at": "2026-08-09T17:13:34.996470+00:00",
|
||||
"ended_at": "2026-08-09T17:13:34.996489+00:00"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"task_id": "task-command_watcher-CMD-test", "title": "CMD-test", "assigned_by": "command-watcher", "executor": "openclaw", "source": "command_watcher", "status": "pending", "priority": "normal", "created_at": "2026-08-09T17:13:47.285193+00:00", "updated_at": "2026-08-09T17:13:47.285198+00:00", "progress": {"percent": 5, "current_step": "pending", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["ops/openclaw/CMD-test.md"], "metadata": {"bucket": "active"}}
|
||||
{"task_id": "task-agent_execution-e1", "title": "Agent execution e1", "assigned_by": "hermes", "executor": "hermes", "source": "agent_execution", "status": "completed", "priority": "normal", "created_at": "2026-08-09T17:13:47.285822+00:00", "updated_at": "2026-08-09T17:13:47.285826+00:00", "progress": {"percent": 100, "current_step": "completed", "completed_steps": [], "remaining_steps": [], "blocked_reason": null}, "notification": {"required": true, "sent": false, "channel": null, "sent_at": null}, "evidence": ["e1"], "metadata": {"execution_id": "e1", "entry_agent": "hermes", "status": "completed"}}
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small CLI used by OpenClaw/Hermes operators to validate AIOS online tasks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def read_token(value: str | None, path: str | None) -> str:
|
||||
if value:
|
||||
return value.strip()
|
||||
if path:
|
||||
return Path(path).read_text(encoding="utf-8").strip()
|
||||
raise SystemExit("token or token-file is required")
|
||||
|
||||
|
||||
def request(base: str, method: str, path: str, token: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
req = Request(
|
||||
base.rstrip("/") + path,
|
||||
data=payload,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Accept": "application/json",
|
||||
**({"Content-Type": "application/json"} if payload is not None else {}),
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", required=True)
|
||||
ap.add_argument("--token")
|
||||
ap.add_argument("--token-file")
|
||||
sub = ap.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("poll")
|
||||
p.add_argument("--limit", type=int, default=20)
|
||||
|
||||
p = sub.add_parser("claim")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
|
||||
p = sub.add_parser("heartbeat")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--lease-seconds", type=int, default=300)
|
||||
p.add_argument("--progress-json", default="{}")
|
||||
|
||||
p = sub.add_parser("result")
|
||||
p.add_argument("task_id")
|
||||
p.add_argument("--lease-token", required=True)
|
||||
p.add_argument("--idempotency-key", required=True)
|
||||
p.add_argument("--result-json", required=True)
|
||||
|
||||
p = sub.add_parser("status")
|
||||
p.add_argument("task_id")
|
||||
|
||||
args = ap.parse_args()
|
||||
token = read_token(args.token, args.token_file)
|
||||
|
||||
if args.command == "poll":
|
||||
status, value = request(args.base, "POST", "/v1/tasks/poll", token, {"limit": args.limit})
|
||||
elif args.command == "claim":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/claim", token,
|
||||
{"lease_seconds": args.lease_seconds},
|
||||
)
|
||||
elif args.command == "heartbeat":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/heartbeat", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"lease_seconds": args.lease_seconds,
|
||||
"progress": json.loads(args.progress_json),
|
||||
},
|
||||
)
|
||||
elif args.command == "result":
|
||||
status, value = request(
|
||||
args.base, "POST", f"/v1/tasks/{args.task_id}/result", token,
|
||||
{
|
||||
"lease_token": args.lease_token,
|
||||
"idempotency_key": args.idempotency_key,
|
||||
"result": json.loads(args.result_json),
|
||||
},
|
||||
)
|
||||
else:
|
||||
status, value = request(args.base, "GET", f"/v1/tasks/{args.task_id}", token)
|
||||
|
||||
print(json.dumps({"http_status": status, "response": value}, ensure_ascii=False, indent=2))
|
||||
return 0 if 200 <= status < 300 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""End-to-end online task feasibility smoke test.
|
||||
|
||||
Runs through the public agent path plus control operations:
|
||||
create -> poll -> claim -> heartbeat -> result -> verify -> readback.
|
||||
It never performs Git, tag, Baseline, or production mutations.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
def token(path: Path) -> str:
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
raise SystemExit("credential_invalid:" + str(path))
|
||||
return value
|
||||
|
||||
|
||||
def call(base: str, method: str, route: str, credential: str, body=None):
|
||||
payload = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + credential,
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if payload is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(base.rstrip("/") + route, data=payload, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
try:
|
||||
value = json.loads(exc.read().decode("utf-8"))
|
||||
except Exception:
|
||||
value = {"error": "HTTP_" + str(exc.code)}
|
||||
return exc.code, value
|
||||
except (URLError, TimeoutError) as exc:
|
||||
raise SystemExit("network_error:" + type(exc).__name__) from exc
|
||||
|
||||
|
||||
def require(step: str, status: int, value, expected_status: str | None = None):
|
||||
if not 200 <= status < 300:
|
||||
raise SystemExit(f"{step}_failed:http={status}:body={json.dumps(value, ensure_ascii=False)}")
|
||||
if expected_status and value.get("status") != expected_status:
|
||||
raise SystemExit(
|
||||
f"{step}_state_failed:expected={expected_status}:"
|
||||
f"observed={value.get('status')}"
|
||||
)
|
||||
print(json.dumps({"step": step, "http": status, "response": value}, ensure_ascii=False))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--control-base", required=True,
|
||||
help="Loopback/model-control base that exposes /v1 control routes")
|
||||
parser.add_argument("--agent-base", required=True,
|
||||
help="Public agent base, e.g. https://host/agent-tasks")
|
||||
parser.add_argument("--control-token-file", required=True, type=Path)
|
||||
parser.add_argument("--agent-token-file", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
control = token(args.control_token_file)
|
||||
agent = token(args.agent_token_file)
|
||||
task_id = "TASK-ONLINE-SMOKE-" + datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(3)
|
||||
create_key = "smoke-create:" + task_id
|
||||
result_key = "smoke-result:" + task_id
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", "/v1/tasks", control,
|
||||
{
|
||||
"task_id": task_id,
|
||||
"goal": "Validate AIOS online task distribution end to end",
|
||||
"instructions": (
|
||||
"Do not mutate Git or production. Return a structured result "
|
||||
"containing marker ONLINE_TASK_FLOW_OK."
|
||||
),
|
||||
"success_criteria": [
|
||||
{"field": "marker", "equals": "ONLINE_TASK_FLOW_OK"},
|
||||
{"field": "agent_observed_task_id", "equals": task_id},
|
||||
],
|
||||
"required_capabilities": ["online-task-smoke"],
|
||||
"constraints": {
|
||||
"git_mutation": False,
|
||||
"production_mutation": False,
|
||||
"network_scope": "AIOS task endpoint only",
|
||||
},
|
||||
"priority": 100,
|
||||
"max_attempts": 3,
|
||||
"idempotency_key": create_key,
|
||||
},
|
||||
)
|
||||
require("create", s, v, "QUEUED")
|
||||
|
||||
s, v = call(args.agent_base, "POST", "/v1/tasks/poll", agent, {"limit": 100})
|
||||
require("poll", s, v)
|
||||
if task_id not in [item.get("task_id") for item in v.get("tasks", [])]:
|
||||
raise SystemExit("poll_failed:created_task_not_visible")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/claim", agent,
|
||||
{"lease_seconds": 120},
|
||||
)
|
||||
require("claim", s, v, "CLAIMED")
|
||||
lease = v.get("lease_token")
|
||||
if not lease:
|
||||
raise SystemExit("claim_failed:lease_token_missing")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/heartbeat", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"lease_seconds": 120,
|
||||
"progress": {"stage": "executing", "percent": 50},
|
||||
},
|
||||
)
|
||||
require("heartbeat", s, v, "RUNNING")
|
||||
|
||||
s, v = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result", s, v, "VERIFYING")
|
||||
|
||||
# Re-submit the identical result to prove idempotency.
|
||||
s2, v2 = call(
|
||||
args.agent_base, "POST", f"/v1/tasks/{task_id}/result", agent,
|
||||
{
|
||||
"lease_token": lease,
|
||||
"idempotency_key": result_key,
|
||||
"result": {
|
||||
"status": "completed",
|
||||
"marker": "ONLINE_TASK_FLOW_OK",
|
||||
"agent_observed_task_id": task_id,
|
||||
"summary": "Agent received, claimed, heartbeated and returned the online task.",
|
||||
"artifacts": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
require("result_idempotent_replay", s2, v2, "VERIFYING")
|
||||
if v2.get("idempotent") is not True:
|
||||
raise SystemExit("result_idempotency_failed")
|
||||
|
||||
s, v = call(
|
||||
args.control_base, "POST", f"/v1/tasks/{task_id}/verify", control,
|
||||
{
|
||||
"decision": "SUCCEEDED",
|
||||
"verification": {
|
||||
"marker_match": "PASS",
|
||||
"task_id_match": "PASS",
|
||||
"online_round_trip": "PASS",
|
||||
},
|
||||
},
|
||||
)
|
||||
require("verify", s, v, "SUCCEEDED")
|
||||
|
||||
s, v = call(args.control_base, "GET", f"/v1/tasks/{task_id}", control)
|
||||
require("final_control_readback", s, v, "SUCCEEDED")
|
||||
|
||||
# The executing Agent must also be able to observe the final terminal state.
|
||||
s, v = call(args.agent_base, "GET", f"/v1/tasks/{task_id}", agent)
|
||||
require("final_agent_readback", s, v, "SUCCEEDED")
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ONLINE_TASK_FLOW_VERIFIED",
|
||||
"task_id": task_id,
|
||||
"final_state": v.get("status"),
|
||||
"force_push_used": False,
|
||||
"tag_rewritten": False,
|
||||
"direct_main_push": False,
|
||||
"production_changed": False,
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build deterministic immutable staging artifact for AIOS Agent Task API."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
|
||||
REQUIRED = (
|
||||
"active-runtime/app/aios_agent_task_protocol.py",
|
||||
"active-runtime/app/aios_agent_task_api.py",
|
||||
"docs/gitops/openapi-agent-task-v1.yaml",
|
||||
)
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source-root", required=True, type=Path)
|
||||
parser.add_argument("--output-root", required=True, type=Path)
|
||||
parser.add_argument("--source-oid", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
oid = args.source_oid.strip().lower()
|
||||
if len(oid) != 40 or any(ch not in "0123456789abcdef" for ch in oid):
|
||||
raise SystemExit("source oid must be exact 40 hex")
|
||||
source = args.source_root.resolve(strict=True)
|
||||
output = args.output_root.resolve()
|
||||
for relative in REQUIRED:
|
||||
if not (source / relative).is_file():
|
||||
raise SystemExit("required source missing: " + relative)
|
||||
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
archive = output / f"aios-agent-task-api-{oid}.tar.gz"
|
||||
manifest = {
|
||||
"schema": "aios.agent-task-artifact.v1",
|
||||
"source_oid": oid,
|
||||
"repository_identity": "aios/aios",
|
||||
"deployment_state": "STAGING",
|
||||
"runtime_mode": "IMMUTABLE_STAGING",
|
||||
"entrypoint": "app/aios_agent_task_api.py",
|
||||
"listen": "127.0.0.1:18940",
|
||||
"plaintext_credentials_in_artifact": False,
|
||||
"included_paths": list(REQUIRED),
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="agent-task-build-", dir=str(output)) as temp:
|
||||
stage = Path(temp) / "runtime"
|
||||
for relative in REQUIRED:
|
||||
src = source / relative
|
||||
if relative.startswith("active-runtime/"):
|
||||
dest = stage / Path(relative).relative_to("active-runtime")
|
||||
else:
|
||||
dest = stage / relative
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(src.read_bytes())
|
||||
(stage / "manifest.json").write_text(
|
||||
json.dumps(manifest, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with tarfile.open(archive, "w:gz", format=tarfile.PAX_FORMAT) as tar:
|
||||
for path in sorted(stage.rglob("*")):
|
||||
arcname = str(path.relative_to(stage.parent)).replace("\\", "/")
|
||||
info = tar.gettarinfo(str(path), arcname=arcname)
|
||||
info.mtime = 0
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = "root"
|
||||
if path.is_file():
|
||||
with path.open("rb") as fh:
|
||||
tar.addfile(info, fh)
|
||||
else:
|
||||
tar.addfile(info)
|
||||
|
||||
receipt = {
|
||||
**manifest,
|
||||
"artifact_path": str(archive),
|
||||
"artifact_sha256": sha256(archive),
|
||||
}
|
||||
receipt_path = archive.with_suffix(archive.suffix + ".receipt.json")
|
||||
receipt_path.write_text(
|
||||
json.dumps(receipt, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps(receipt, sort_keys=True, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -20,12 +20,3 @@ def test_unified_event_flow():
|
||||
|
||||
dashboard = AgentEventDashboard(bus).overview()
|
||||
assert dashboard["audit"]["enabled"] is True
|
||||
|
||||
|
||||
def test_target_changes_event_identity(tmp_path):
|
||||
bus = AgentEventBus(tmp_path)
|
||||
first = bus.publish('test', 'same', 'user-a', 'same summary', {'value': 1})
|
||||
second = bus.publish('test', 'same', 'user-b', 'same summary', {'value': 1})
|
||||
first_id = first.get('id') if isinstance(first, dict) else getattr(first, 'id', first)
|
||||
second_id = second.get('id') if isinstance(second, dict) else getattr(second, 'id', second)
|
||||
assert first_id != second_id
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_model_api_exposes_governed_writer_without_legacy_global_enable():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "'/createGovernedTask': ('/v2/governed-task'" in source
|
||||
assert "'/commitFilesGoverned': ('/v2/commit-files'" in source
|
||||
assert "path == '/api/governed-write-capabilities'" in source
|
||||
assert "AIOS_GITOPS_WRITES_ENABLED" not in source or "legacy_global_writes_enabled" in source
|
||||
assert "'writes_enabled': False" in source
|
||||
assert "bridge_v2_json" in source
|
||||
|
||||
|
||||
def test_model_api_routes_online_task_control_through_loopback_credential():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "AIOS_AGENT_TASK_API_URL" in source
|
||||
assert "AIOS_AGENT_TASK_CONTROL_TOKEN_FILE" in source
|
||||
assert "/createOnlineAgentTask" in source
|
||||
assert "/verifyOnlineAgentTask" in source
|
||||
assert "/cancelOnlineAgentTask" in source
|
||||
assert "agent-task:control" in source
|
||||
|
||||
|
||||
def test_online_task_service_keeps_control_endpoints_off_public_nginx():
|
||||
nginx = (ROOT / "deploy/nginx/aios-agent-task-api.conf.example").read_text(encoding="utf-8")
|
||||
assert "/agent-tasks/v1/tasks/poll" in nginx
|
||||
assert "(claim|heartbeat|result)" in nginx
|
||||
assert "/v1/agents/register" not in nginx.replace(
|
||||
"# Control endpoints (/v1/agents/register, POST /v1/tasks, verify/cancel/events)", ""
|
||||
)
|
||||
|
||||
|
||||
def test_online_task_systemd_uses_runtime_credential_file():
|
||||
unit = (ROOT / "deploy/systemd/aios-agent-task-api.service").read_text(encoding="utf-8")
|
||||
assert "LoadCredential=control_token:" in unit
|
||||
assert "/run/credentials/aios-agent-task-api.service/control_token" in unit
|
||||
assert "ReadWritePaths=/var/lib/aios-agent-task" in unit
|
||||
assert "127.0.0.1" in unit
|
||||
|
||||
|
||||
def test_control_plane_snapshot_is_local_aggregate_not_missing_bridge_route():
|
||||
source = (ROOT / "active-runtime/app/aios_model_api_staging.py").read_text(encoding="utf-8")
|
||||
assert "def control_plane_snapshot(self):" in source
|
||||
assert "'snapshot_source': 'model-api-local-aggregate'" in source
|
||||
assert "return self.control_plane_snapshot()" in source
|
||||
assert "bridge_v2_json('/v2/control-plane-snapshot'" not in source
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self) -> None:
|
||||
self.value = datetime(2026, 8, 9, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
def __call__(self) -> datetime:
|
||||
return self.value
|
||||
|
||||
def advance(self, seconds: int) -> None:
|
||||
self.value += timedelta(seconds=seconds)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path: Path):
|
||||
clock = Clock()
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3", now=clock, token_pepper="test-pepper")
|
||||
a1 = store.register_agent(
|
||||
agent_id="openclaw-1",
|
||||
agent_type="openclaw",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
a2 = store.register_agent(
|
||||
agent_id="hermes-1",
|
||||
agent_type="hermes",
|
||||
agent_version="test",
|
||||
capabilities=["shell", "git", "python"],
|
||||
)
|
||||
return store, clock, a1, a2
|
||||
|
||||
|
||||
def create_task(store: TaskStore, task_id: str = "TASK-ONLINE-001"):
|
||||
return store.create_task(
|
||||
task_id=task_id,
|
||||
goal="Create a deterministic validation artifact",
|
||||
instructions="Write validation.txt containing ONLINE_TASK_OK.",
|
||||
success_criteria=[
|
||||
{"type": "file_exists", "path": "validation.txt"},
|
||||
{"type": "contains", "path": "validation.txt", "value": "ONLINE_TASK_OK"},
|
||||
],
|
||||
required_capabilities=["shell"],
|
||||
constraints={"network": "optional"},
|
||||
metadata={"suite": "online-task"},
|
||||
priority=10,
|
||||
max_attempts=3,
|
||||
idempotency_key="create:" + task_id,
|
||||
)
|
||||
|
||||
|
||||
def test_full_flow_requires_server_verification(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
|
||||
polled = store.poll_tasks(agent_id=a1["agent_id"])
|
||||
assert [item["task_id"] for item in polled] == ["TASK-ONLINE-001"]
|
||||
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert claim["status"] == "CLAIMED"
|
||||
assert claim["lease_token"].startswith("lease_")
|
||||
|
||||
running = store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
progress={"percent": 50},
|
||||
lease_seconds=300,
|
||||
)
|
||||
assert running["status"] == "RUNNING"
|
||||
|
||||
submitted = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={
|
||||
"status": "completed",
|
||||
"summary": "Created validation.txt",
|
||||
"artifacts": [{"path": "validation.txt", "sha256": "0" * 64}],
|
||||
},
|
||||
)
|
||||
assert submitted["status"] == "VERIFYING"
|
||||
assert submitted["status"] != "SUCCEEDED"
|
||||
|
||||
verified = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="SUCCEEDED",
|
||||
verification={"checks": {"content": "PASS"}},
|
||||
)
|
||||
assert verified["status"] == "SUCCEEDED"
|
||||
|
||||
|
||||
def test_atomic_claim_allows_only_one_agent(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store)
|
||||
outcomes: list[tuple[str, str]] = []
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def runner(agent_id: str) -> None:
|
||||
barrier.wait()
|
||||
try:
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=agent_id,
|
||||
lease_seconds=300,
|
||||
)
|
||||
outcomes.append((agent_id, "CLAIMED"))
|
||||
except TaskProtocolError as exc:
|
||||
outcomes.append((agent_id, exc.code))
|
||||
|
||||
t1 = threading.Thread(target=runner, args=(a1["agent_id"],))
|
||||
t2 = threading.Thread(target=runner, args=(a2["agent_id"],))
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert sum(result == "CLAIMED" for _, result in outcomes) == 1
|
||||
assert sum(result in {"TASK_NOT_CLAIMABLE", "TASK_CLAIM_CONFLICT"} for _, result in outcomes) == 1
|
||||
|
||||
|
||||
def test_expired_lease_is_requeued_and_reclaimable(env):
|
||||
store, clock, a1, a2 = env
|
||||
create_task(store)
|
||||
first = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert first["attempt"] == 1
|
||||
|
||||
clock.advance(31)
|
||||
available = store.poll_tasks(agent_id=a2["agent_id"])
|
||||
assert available[0]["status"] == "QUEUED"
|
||||
|
||||
second = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=30,
|
||||
)
|
||||
assert second["attempt"] == 2
|
||||
assert second["claimed_by"] == a2["agent_id"]
|
||||
|
||||
|
||||
def test_wrong_lease_token_is_rejected(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
with pytest.raises(TaskProtocolError, match="TASK_LEASE_TOKEN_INVALID"):
|
||||
store.heartbeat(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token="lease_wrong",
|
||||
progress={},
|
||||
)
|
||||
|
||||
|
||||
def test_result_submission_is_idempotent(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
payload = {"status": "completed", "summary": "ok"}
|
||||
first = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
second = store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result=payload,
|
||||
)
|
||||
assert first["status"] == "VERIFYING"
|
||||
assert second["status"] == "VERIFYING"
|
||||
assert second["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:same",
|
||||
result={"status": "completed", "summary": "different"},
|
||||
)
|
||||
|
||||
|
||||
def test_needs_rework_returns_to_queue(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store)
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=300,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-ONLINE-001",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="result:1",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
result = store.verify_task(
|
||||
task_id="TASK-ONLINE-001",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"reason": "success criteria not met"},
|
||||
)
|
||||
assert result["status"] == "QUEUED"
|
||||
assert result["claimed_by"] is None
|
||||
|
||||
|
||||
def test_capability_filter(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
store.create_task(
|
||||
task_id="TASK-GPU-001",
|
||||
goal="GPU task",
|
||||
instructions="Run GPU workload",
|
||||
success_criteria=[],
|
||||
required_capabilities=["gpu"],
|
||||
idempotency_key="create:gpu",
|
||||
)
|
||||
assert store.poll_tasks(agent_id=a1["agent_id"]) == []
|
||||
|
||||
|
||||
def test_create_task_idempotency_conflict(env):
|
||||
store, _clock, _a1, _a2 = env
|
||||
first = create_task(store)
|
||||
replay = create_task(store)
|
||||
assert first["task_id"] == replay["task_id"]
|
||||
assert replay["idempotent"] is True
|
||||
|
||||
with pytest.raises(TaskProtocolError, match="IDEMPOTENCY_CONFLICT"):
|
||||
store.create_task(
|
||||
task_id="TASK-ONLINE-002",
|
||||
goal="different",
|
||||
instructions="different",
|
||||
success_criteria=[],
|
||||
idempotency_key="create:TASK-ONLINE-001",
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_success_retains_executing_agent_for_final_readback(env):
|
||||
store, _clock, a1, _a2 = env
|
||||
create_task(store, "TASK-TERMINAL-READBACK")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="terminal-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
final = store.verify_task(
|
||||
task_id="TASK-TERMINAL-READBACK",
|
||||
decision="SUCCEEDED",
|
||||
verification={"criteria": "PASS"},
|
||||
)
|
||||
assert final["status"] == "SUCCEEDED"
|
||||
assert final["claimed_by"] == a1["agent_id"]
|
||||
|
||||
|
||||
def test_needs_rework_releases_agent_ownership_for_reclaim(env):
|
||||
store, _clock, a1, a2 = env
|
||||
create_task(store, "TASK-REWORK-OWNERSHIP")
|
||||
claim = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
store.submit_result(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a1["agent_id"],
|
||||
lease_token=claim["lease_token"],
|
||||
idempotency_key="rework-owner-result",
|
||||
result={"status": "completed"},
|
||||
)
|
||||
requeued = store.verify_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
decision="NEEDS_REWORK",
|
||||
verification={"criteria": "FAIL"},
|
||||
)
|
||||
assert requeued["status"] == "QUEUED"
|
||||
assert requeued["claimed_by"] is None
|
||||
reclaimed = store.claim_task(
|
||||
task_id="TASK-REWORK-OWNERSHIP",
|
||||
agent_id=a2["agent_id"],
|
||||
lease_seconds=120,
|
||||
)
|
||||
assert reclaimed["claimed_by"] == a2["agent_id"]
|
||||
@@ -2,7 +2,6 @@ import base64
|
||||
from pathlib import Path
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
||||
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
@@ -27,7 +26,6 @@ class Probe:
|
||||
|
||||
def test_create_new_lease_is_exact_base_and_absent():
|
||||
request = WriterLeaseRequestV2(
|
||||
job_id="job-bridge-runtime-lineage-test",
|
||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||
repository="aios/aios",
|
||||
branch_mode="CREATE_NEW",
|
||||
@@ -41,7 +39,6 @@ def test_create_new_lease_is_exact_base_and_absent():
|
||||
|
||||
def test_create_new_lease_rejects_existing_target():
|
||||
request = WriterLeaseRequestV2(
|
||||
job_id="job-bridge-runtime-lineage-test",
|
||||
task_id="task",
|
||||
repository="aios/aios",
|
||||
branch_mode="CREATE_NEW",
|
||||
@@ -77,8 +74,8 @@ def test_slash_branch_uses_paginated_collection():
|
||||
|
||||
def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task", "/v2/execution-envelope", "/v2/execution-plan",
|
||||
"/v2/writer-lease", "/v2/writer-lease/bootstrap", "/v2/writer-lease/renew", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
"/v2/tag/publish",
|
||||
@@ -91,150 +88,3 @@ def test_http_route_contract_is_complete():
|
||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||
|
||||
assert set(ROUTES) == expected
|
||||
|
||||
|
||||
def test_renew_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||
adapter = BridgeV2Adapter(runtime_status={})
|
||||
capabilities = adapter.getGovernedWriteCapabilities()
|
||||
contract = capabilities["operation_contracts"]["renewWriterLease"]
|
||||
|
||||
assert contract["method"] == "POST"
|
||||
assert contract["path"] == "/v2/writer-lease/renew"
|
||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["renewWriterLease"]
|
||||
assert "runtime_rehydrated" in contract["response_fields"]
|
||||
|
||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
assert "/v2/writer-lease/renew:" in action_schema
|
||||
assert "operationId: renewWriterLease" in action_schema
|
||||
assert "BridgeWriterLeaseRecoveryRequest" in action_schema
|
||||
assert "BridgeWriterLeaseRecoveryResponse" in action_schema
|
||||
assert {
|
||||
capabilities["operation_contracts"][operation]["path"]
|
||||
for operation in (
|
||||
"acquireWriterLease",
|
||||
"renewWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
)
|
||||
} == {
|
||||
"/v2/writer-lease",
|
||||
"/v2/writer-lease/renew",
|
||||
"/v2/commit-file",
|
||||
"/v2/commit-files",
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||
adapter = BridgeV2Adapter(runtime_status={})
|
||||
capabilities = adapter.getGovernedWriteCapabilities()
|
||||
contract = capabilities["operation_contracts"]["bootstrapWriterLease"]
|
||||
|
||||
assert contract["method"] == "POST"
|
||||
assert contract["path"] == "/v2/writer-lease/bootstrap"
|
||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"]
|
||||
assert "bootstrapped" in contract["response_fields"]
|
||||
assert "durable_branch_lease_present" in contract["response_fields"]
|
||||
|
||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
assert "/v2/writer-lease/bootstrap:" in action_schema
|
||||
assert "operationId: bootstrapWriterLease" in action_schema
|
||||
assert "bootstrapped: {type: boolean}" in action_schema
|
||||
assert "durable_branch_lease_present: {type: boolean}" in action_schema
|
||||
|
||||
|
||||
|
||||
def test_http_allows_explicit_job_id_on_lease_and_commit_requests():
|
||||
handler = BridgeHandler.__new__(BridgeHandler)
|
||||
lease_body = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": "task",
|
||||
"job_id": "job-explicit-runtime-lineage",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/explicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
handler._allowed("acquireWriterLease", lease_body)
|
||||
handler._allowed(
|
||||
"commitFilesGoverned",
|
||||
{
|
||||
"task_id": "task",
|
||||
"job_id": "job-explicit-runtime-lineage",
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/explicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"idempotency_key": "commit-explicit-runtime-lineage",
|
||||
"files": {"docs/x.txt": "eA=="},
|
||||
"message": "test",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_http_allows_omitted_job_id_for_durable_server_resolution():
|
||||
handler = BridgeHandler.__new__(BridgeHandler)
|
||||
lease_body = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": "task",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/implicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
handler._allowed("acquireWriterLease", lease_body)
|
||||
handler._allowed(
|
||||
"commitFilesGoverned",
|
||||
{
|
||||
"task_id": "task",
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/implicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"idempotency_key": "commit-implicit-runtime-lineage",
|
||||
"files": {"docs/x.txt": "eA=="},
|
||||
"message": "test",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_http_mutation_routes_require_exact_runtime_principal():
|
||||
handler = BridgeHandler.__new__(BridgeHandler)
|
||||
runtime_principal = "runtime-" "agent-principal-token-1234"
|
||||
handler.control_token = runtime_principal
|
||||
handler.agent_task_api_url = "http://127.0.0.1:1"
|
||||
handler.headers = {"Authorization": f"Bearer {runtime_principal}"}
|
||||
assert handler._authorized("createGovernedTask", {}) is True
|
||||
handler.headers = {"Authorization": "Bearer wrong-runtime-principal-token"}
|
||||
assert handler._authorized("createGovernedTask", {}) is False
|
||||
handler.headers = {}
|
||||
assert handler._authorized("createGovernedTask", {}) is False
|
||||
|
||||
|
||||
def test_capability_contracts_return_exact_job_id_lineage():
|
||||
contracts = BridgeV2Adapter(runtime_status={}).getGovernedWriteCapabilities()["operation_contracts"]
|
||||
for operation in ("acquireWriterLease", "bootstrapWriterLease", "renewWriterLease"):
|
||||
assert "job_id" in contracts[operation]["response_fields"]
|
||||
for operation in ("commitFileGoverned", "commitFilesGoverned"):
|
||||
assert "job_id" in contracts[operation]["response_fields"]
|
||||
|
||||
|
||||
def test_online_agent_execution_routes_and_action_projection_are_exposed():
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||
assert ROUTES["/v2/execution-envelope"] == "projectOnlineAgentExecution"
|
||||
assert ROUTES["/v2/execution-plan"] == "registerOnlineAgentExecutionPlan"
|
||||
adapter = BridgeV2Adapter(runtime_status={})
|
||||
contracts = adapter.getGovernedWriteCapabilities()["operation_contracts"]
|
||||
assert tuple(contracts["projectOnlineAgentExecution"]["required_fields"]) == OPERATION_REQUIRED_FIELDS["projectOnlineAgentExecution"]
|
||||
assert tuple(contracts["registerOnlineAgentExecutionPlan"]["required_fields"]) == OPERATION_REQUIRED_FIELDS["registerOnlineAgentExecutionPlan"]
|
||||
schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
assert "/v2/execution-envelope:" in schema
|
||||
assert "operationId: projectOnlineAgentExecution" in schema
|
||||
assert "/v2/execution-plan:" in schema
|
||||
assert "operationId: registerOnlineAgentExecutionPlan" in schema
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"aios_durable_owner_authorization",
|
||||
APP / "aios_durable_owner_authorization.py",
|
||||
)
|
||||
BROKER_MODULE = importlib.util.module_from_spec(SPEC)
|
||||
import sys
|
||||
sys.modules[SPEC.name] = BROKER_MODULE
|
||||
assert SPEC.loader is not None
|
||||
SPEC.loader.exec_module(BROKER_MODULE)
|
||||
|
||||
AuthorizationError = BROKER_MODULE.AuthorizationError
|
||||
Broker = BROKER_MODULE.Broker
|
||||
Constraints = BROKER_MODULE.Constraints
|
||||
DurablePolicy = BROKER_MODULE.DurablePolicy
|
||||
ReceiptStore = BROKER_MODULE.ReceiptStore
|
||||
Upstream = BROKER_MODULE.Upstream
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
SOURCE = "257c7a8df433f75b364bcaf69cbbc32e8c2e5d38"
|
||||
|
||||
|
||||
def policy(*, expires_at=None, capabilities=None):
|
||||
return DurablePolicy(
|
||||
grant_id="grant-caller-projection-v1",
|
||||
owner_identity="owner-durable-policy",
|
||||
account=CALLER,
|
||||
capabilities=frozenset(capabilities or {SCOPE}),
|
||||
issued_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
not_before=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
persistent_until_revoked=True,
|
||||
constraints=Constraints(
|
||||
repository="aios/aios",
|
||||
account=CALLER,
|
||||
allowed_target_prefix="agent/",
|
||||
release_version_prefix="V2R2C",
|
||||
max_task_risk="engineering",
|
||||
),
|
||||
policy_sha256="a" * 64,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
|
||||
|
||||
def make_broker(root):
|
||||
return Broker(
|
||||
policy_path=root / "policy.json",
|
||||
revocation_path=root / "revocations.json",
|
||||
store=ReceiptStore(root / "receipts.sqlite3"),
|
||||
upstream=Upstream("http://127.0.0.1:1", "not-used"),
|
||||
expected_account=CALLER,
|
||||
expected_peer_user="test-peer",
|
||||
allowed_peer_users=frozenset({"test-peer"}),
|
||||
)
|
||||
|
||||
|
||||
class CallerProjectionTests(unittest.TestCase):
|
||||
def project(self, broker, request=None):
|
||||
return broker.project_caller(
|
||||
CALLER,
|
||||
SCOPE,
|
||||
request or {"source_commit": SOURCE, "version": "V2R2C2"},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
|
||||
def test_positive_projection_ack_and_effective_scope(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
self.assertGreaterEqual(len(credential), 32)
|
||||
self.assertEqual("ACKED", value["projection_state"])
|
||||
self.assertEqual("REHYDRATED", value["credential_state"])
|
||||
self.assertEqual([SCOPE], value["effective_scopes"])
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_secret_material_is_not_durable(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
broker = make_broker(root)
|
||||
with patch.object(broker, "policy", return_value=policy()):
|
||||
value = self.project(broker)
|
||||
credential = value.pop("_credential")
|
||||
durable = (root / "receipts.sqlite3").read_bytes()
|
||||
self.assertNotIn(credential.encode(), durable)
|
||||
self.assertNotIn("credential_material", durable.decode("utf-8", "ignore"))
|
||||
self.assertFalse(value["secret_exposed"])
|
||||
|
||||
def test_caller_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
"other-caller",
|
||||
SCOPE,
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("CALLER_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_scope_mismatch_is_denied(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
broker.project_caller(
|
||||
CALLER,
|
||||
"release:deploy",
|
||||
{},
|
||||
idempotency_key="caller-projection-test-v1",
|
||||
)
|
||||
self.assertEqual("SCOPE_MISMATCH", caught.exception.code)
|
||||
|
||||
def test_policy_missing_revoked_and_expired_fail_closed(self):
|
||||
broker = make_broker(Path(tempfile.mkdtemp()))
|
||||
for code in ("POLICY_FILE_INVALID", "OWNER_GRANT_REVOKED", "POLICY_EXPIRED"):
|
||||
with self.subTest(code=code):
|
||||
with patch.object(
|
||||
broker,
|
||||
"policy",
|
||||
side_effect=AuthorizationError(code, 403),
|
||||
):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
self.project(broker)
|
||||
self.assertEqual(code, caught.exception.code)
|
||||
|
||||
def test_invalid_projection_state_fails_closed(self):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
Broker.validate_caller_projection(
|
||||
{
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"projection_state": "CORRUPT",
|
||||
"credential_state": "REHYDRATED",
|
||||
"rehydration_state": "RECOVERED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"secret_exposed": False,
|
||||
}
|
||||
)
|
||||
self.assertEqual("PROJECTION_STATE_INVALID", caught.exception.code)
|
||||
|
||||
def test_restart_rehydrates_from_durable_metadata(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
first = make_broker(root)
|
||||
with patch.object(first, "policy", return_value=policy()):
|
||||
initial = self.project(first)
|
||||
first_credential = initial.pop("_credential")
|
||||
second = make_broker(root)
|
||||
with patch.object(second, "policy", return_value=policy()):
|
||||
recovered = self.project(second)
|
||||
second_credential = recovered.pop("_credential")
|
||||
self.assertNotEqual(first_credential, second_credential)
|
||||
self.assertEqual("RECOVERED", recovered["rehydration_state"])
|
||||
self.assertEqual("ACKED", recovered["projection_state"])
|
||||
|
||||
def test_expired_policy_loader_rejects(self):
|
||||
root = Path(tempfile.mkdtemp())
|
||||
policy_path = root / "policy.json"
|
||||
payload = {
|
||||
"schema": "aios.owner-durable-authorization.v1",
|
||||
"grant_id": "grant-caller-projection-v1",
|
||||
"owner_identity": "owner-durable-policy",
|
||||
"account": CALLER,
|
||||
"capabilities": [SCOPE],
|
||||
"issued_at": "2026-08-22T00:00:00Z",
|
||||
"not_before": "2026-08-22T00:00:00Z",
|
||||
"expires_at": "2026-08-22T01:00:00Z",
|
||||
"persistent_until_revoked": True,
|
||||
"constraints": {
|
||||
"repository": "aios/aios",
|
||||
"allowed_target_prefix": "agent/",
|
||||
"release_version_prefix": "V2R2C",
|
||||
"max_task_risk": "engineering",
|
||||
},
|
||||
}
|
||||
policy_path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
with patch.object(BROKER_MODULE, "validate_root_file", lambda path, **kwargs: path):
|
||||
with self.assertRaises(AuthorizationError) as caught:
|
||||
DurablePolicy.load(policy_path)
|
||||
self.assertEqual("POLICY_EXPIRED", caught.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
try:
|
||||
import orchestrator.action_monitor
|
||||
except ModuleNotFoundError:
|
||||
_orchestrator = types.ModuleType("orchestrator")
|
||||
_action_monitor = types.ModuleType("orchestrator.action_monitor")
|
||||
_action_monitor.ActionMonitor = type("ActionMonitor", (), {})
|
||||
_orchestrator.__path__ = []
|
||||
sys.modules["orchestrator"] = _orchestrator
|
||||
sys.modules["orchestrator.action_monitor"] = _action_monitor
|
||||
|
||||
import aios_durable_owner_authorization as broker_api
|
||||
import aios_model_api_staging as model_api
|
||||
|
||||
|
||||
CALLER = "chatgpt-governed-implementer-singlehost"
|
||||
SCOPE = "release:candidate"
|
||||
|
||||
|
||||
def _invoke_capabilities(
|
||||
headers,
|
||||
*,
|
||||
auth_result=None,
|
||||
auth_side_effect=None,
|
||||
projection_client=None,
|
||||
):
|
||||
handler = object.__new__(model_api.H)
|
||||
handler.headers = headers
|
||||
responses = []
|
||||
handler.sendj = lambda value, code=200: responses.append((value, code))
|
||||
if auth_side_effect is not None:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", side_effect=auth_side_effect
|
||||
)
|
||||
else:
|
||||
auth_patch = patch.object(
|
||||
model_api, "authenticate", return_value=auth_result
|
||||
)
|
||||
client = (
|
||||
projection_client
|
||||
if projection_client is not None
|
||||
else model_api.CALLER_PROJECTION_CLIENT
|
||||
)
|
||||
with auth_patch, patch.object(model_api, "CALLER_PROJECTION_CLIENT", client):
|
||||
model_api.H.caller_capabilities(handler)
|
||||
assert len(responses) == 1
|
||||
return responses[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[
|
||||
{},
|
||||
{"Authorization": "Bearer invalid"},
|
||||
{"Authorization": "Bearer unknown"},
|
||||
{"X-AIOS-Token": "rec-none"},
|
||||
],
|
||||
)
|
||||
def test_anonymous_invalid_unknown_and_rec_none_are_structured_401(headers):
|
||||
payload, status = _invoke_capabilities(headers, auth_result=None)
|
||||
assert status == 401
|
||||
assert payload == {"error": "unauthorized", "hint": "Bearer token required"}
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authenticate_exception_is_fail_closed_without_socket_abort():
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer malformed"},
|
||||
auth_side_effect=RuntimeError("malformed"),
|
||||
)
|
||||
assert status == 401
|
||||
assert payload["error"] == "unauthorized"
|
||||
assert "Traceback" not in json.dumps(payload)
|
||||
|
||||
|
||||
def test_authorized_capability_introspection_remains_secret_free():
|
||||
row = {
|
||||
"account": CALLER,
|
||||
"role": "user",
|
||||
"scopes": ["agent-task:control", "branch:create", "file:write"],
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
public={
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
},
|
||||
credential_loaded=True,
|
||||
)
|
||||
|
||||
class _ProjectionClient:
|
||||
def rehydrate(self, caller, scope, request):
|
||||
assert caller == CALLER
|
||||
assert scope == SCOPE
|
||||
assert request == {}
|
||||
return result
|
||||
|
||||
payload, status = _invoke_capabilities(
|
||||
{"Authorization": "Bearer valid"},
|
||||
auth_result=row,
|
||||
projection_client=_ProjectionClient(),
|
||||
)
|
||||
assert status == 200
|
||||
assert payload["caller_identity"] == CALLER
|
||||
assert payload["admitted"] is True
|
||||
assert SCOPE in payload["effective_scopes"]
|
||||
assert payload["projection_state"] == "ACKED"
|
||||
assert payload["credential_state"] == "ACTIVE"
|
||||
assert payload["rehydration_state"] == "REHYDRATED"
|
||||
assert payload["secret_exposed"] is False
|
||||
assert "_credential" not in json.dumps(payload)
|
||||
|
||||
|
||||
class _FakeBroker:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def project_caller(self, caller, scope, request, *, idempotency_key):
|
||||
self.calls.append((caller, scope, request, idempotency_key))
|
||||
return {
|
||||
"caller": caller,
|
||||
"scope": scope,
|
||||
"projection_state": "ACKED",
|
||||
"credential_state": "ACTIVE",
|
||||
"rehydration_state": "REHYDRATED",
|
||||
"effective_scopes": [SCOPE],
|
||||
"_credential": "x" * 40,
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_broker(path="/v1/project-caller", *, peer=True, account=True):
|
||||
broker = _FakeBroker()
|
||||
handler = object.__new__(broker_api.Handler)
|
||||
handler.path = path
|
||||
handler.broker = broker
|
||||
handler.peer_authorized = lambda: peer
|
||||
handler.account_authorized = lambda: account
|
||||
handler.body = lambda: {
|
||||
"caller": CALLER,
|
||||
"scope": SCOPE,
|
||||
"idempotency_key": "runtime-regression-test-20260823",
|
||||
"request": {
|
||||
"repository": "aios/aios",
|
||||
"target_branch": "agent/runtime-regression-test",
|
||||
"release_version": "V2R2C2",
|
||||
},
|
||||
}
|
||||
responses = []
|
||||
handler.send_json = lambda value, status, internal_credential=None: responses.append(
|
||||
(value, status, internal_credential)
|
||||
)
|
||||
broker_api.Handler.do_POST(handler)
|
||||
return responses, broker
|
||||
|
||||
|
||||
def test_existing_project_caller_route_reaches_handler_without_public_secret():
|
||||
responses, broker = _dispatch_broker()
|
||||
assert len(responses) == 1
|
||||
value, status, internal_credential = responses[0]
|
||||
assert status == 200
|
||||
assert broker.calls
|
||||
assert broker.calls[0][0:2] == (CALLER, SCOPE)
|
||||
assert internal_credential == "x" * 40
|
||||
assert "_credential" not in value
|
||||
assert "x" * 40 not in json.dumps(value)
|
||||
|
||||
|
||||
def test_unauthorized_peer_is_denied_and_unregistered_route_is_not_added():
|
||||
responses, broker = _dispatch_broker(peer=False)
|
||||
assert responses[0][1] == 403
|
||||
assert broker.calls == []
|
||||
|
||||
responses, broker = _dispatch_broker(path="/v1/not-registered")
|
||||
assert responses[0][1] == 404
|
||||
assert broker.calls == []
|
||||
@@ -1,307 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
from aios_continuation_executor import ContinuationExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import JobState
|
||||
|
||||
|
||||
TASK = "continuation-normalization-test"
|
||||
JOB = "job-continuation-normalization-test"
|
||||
REPO = "aios/aios"
|
||||
BASE = "a" * 40
|
||||
BRANCH = "agent/continuation-normalization-test"
|
||||
|
||||
|
||||
class Authority:
|
||||
def __init__(self, head: str | None) -> None:
|
||||
self.head = head
|
||||
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return self.head
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
return oid == BASE
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
assert branch == BRANCH
|
||||
return self.head is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
assert branch == BRANCH and self.head is not None
|
||||
return self.head
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return older == BASE and newer == BASE
|
||||
|
||||
|
||||
def lease(**overrides: object) -> WriterLeaseRequestV2:
|
||||
values = {
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": REPO,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
values.update(overrides)
|
||||
return WriterLeaseRequestV2(**values)
|
||||
|
||||
|
||||
def runtime(*, head: str | None = BASE, **binding_overrides: object) -> GovernedRuntime:
|
||||
value = GovernedRuntime.__new__(GovernedRuntime)
|
||||
value.authority = Authority(head)
|
||||
binding = {
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": REPO,
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
}
|
||||
binding.update(binding_overrides)
|
||||
value.bindings = {TASK: binding}
|
||||
return value
|
||||
|
||||
|
||||
def test_same_lineage_create_new_normalizes_to_continue_existing() -> None:
|
||||
normalized = runtime()._normalize_runtime_lease(lease())
|
||||
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
||||
assert normalized.task_id == TASK
|
||||
assert normalized.job_id == JOB
|
||||
assert normalized.repository == REPO
|
||||
assert normalized.target_branch == BRANCH
|
||||
assert normalized.expected_branch_oid == BASE
|
||||
assert normalized.generation == 1
|
||||
|
||||
|
||||
def test_unprovisioned_create_new_stays_create_new() -> None:
|
||||
assert runtime(head=None)._normalize_runtime_lease(lease()).branch_mode == "CREATE_NEW"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
(
|
||||
("repository", "other/repo"),
|
||||
("base_oid", "b" * 40),
|
||||
("expected_branch_oid", "b" * 40),
|
||||
("target_branch", "agent/foreign"),
|
||||
("generation", 2),
|
||||
),
|
||||
)
|
||||
def test_binding_mismatch_fails_closed(field: str, value: object) -> None:
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime(**{field: value})._normalize_runtime_lease(lease())
|
||||
|
||||
|
||||
def test_drifted_existing_head_fails_closed() -> None:
|
||||
with pytest.raises(RuntimeError, match="EXPECTED_BRANCH_OID_CONFLICT"):
|
||||
runtime(head="b" * 40)._normalize_runtime_lease(lease())
|
||||
|
||||
|
||||
def test_openapi_contract_parses_and_requires_exact_commit_lineage() -> None:
|
||||
document = yaml.safe_load(
|
||||
Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
schema = document["components"]["schemas"]["BridgeCommitResponse"]
|
||||
assert {
|
||||
"job_id",
|
||||
"commit_oid",
|
||||
"commit_visible",
|
||||
"branch_mode",
|
||||
"target_branch",
|
||||
"generation",
|
||||
} <= set(schema["required"])
|
||||
|
||||
|
||||
def test_changed_production_files_pass_secret_scan() -> None:
|
||||
paths = [
|
||||
Path("active-runtime/app/aios_authority_git_executor.py"),
|
||||
Path("active-runtime/app/aios_chatgpt_bridge_v2_server.py"),
|
||||
Path("active-runtime/app/aios_continuation_executor.py"),
|
||||
Path("active-runtime/app/aios_continuation_lease.py"),
|
||||
Path("active-runtime/app/authorized_task_writer_provisioning.py"),
|
||||
Path("active-runtime/app/aios_chatgpt_bridge_v2.py"),
|
||||
Path("docs/gitops/openapi-chatgpt-actions.yaml"),
|
||||
]
|
||||
expression = re.compile(
|
||||
r"(?i)(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|"
|
||||
r"authorization:\\s*bearer\\s+[A-Za-z0-9._-]{12,}|"
|
||||
r"api[_-]?key\\s*[:=]\\s*['\\\"][^'\\\"]{12,})"
|
||||
)
|
||||
hits = []
|
||||
for path in paths:
|
||||
for number, line in enumerate(
|
||||
path.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
||||
):
|
||||
if expression.search(line):
|
||||
hits.append(f"{path}:{number}")
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_remote_verified_job_is_not_completed_when_head_cas_callback_fails() -> None:
|
||||
class Store:
|
||||
def read(self, _job_id):
|
||||
return {
|
||||
"branch": BRANCH,
|
||||
"request_json": json.dumps(
|
||||
{"binding": {"expected_branch_oid": BASE}}
|
||||
),
|
||||
}
|
||||
|
||||
class Controller:
|
||||
def __init__(self):
|
||||
self.store = Store()
|
||||
self.states = []
|
||||
|
||||
def advance(self, _job_id, state, **_kwargs):
|
||||
self.states.append(state)
|
||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||
|
||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||
executor.controller = Controller()
|
||||
executor._observe_remote = lambda _branch, oid: (oid, oid, True)
|
||||
|
||||
def fail_cas(_oid):
|
||||
raise RuntimeError("LEASE_CAS_CONFLICT")
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||
executor._finish_remote_verification(
|
||||
JOB,
|
||||
"b" * 40,
|
||||
"b" * 40,
|
||||
before_complete=fail_cas,
|
||||
)
|
||||
|
||||
assert executor.controller.states == [JobState.REMOTE_VERIFIED]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch_mode", ("CREATE_NEW", "CONTINUE_EXISTING"))
|
||||
def test_executor_preflight_accepts_the_exact_mode_dependent_normalized_shape(
|
||||
branch_mode: str,
|
||||
) -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from aios_continuation_executor import (
|
||||
ContinuationPermissions,
|
||||
ContinuationRuntimeBinding,
|
||||
)
|
||||
|
||||
request = lease(branch_mode=branch_mode)
|
||||
now = datetime.now(timezone.utc)
|
||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||
executor.authority = Authority(None if branch_mode == "CREATE_NEW" else BASE)
|
||||
executor.now = lambda: now
|
||||
executor._max_generation = lambda _branch, *, exclude_job_id=None: 0
|
||||
executor.workspace = SimpleNamespace(
|
||||
is_detached=lambda: True,
|
||||
head_oid=lambda: BASE,
|
||||
)
|
||||
executor.remote = SimpleNamespace(repository_identity=lambda: REPO)
|
||||
binding = ContinuationRuntimeBinding(
|
||||
request=request,
|
||||
job_id=JOB,
|
||||
workspace_path=Path("/tmp/continuation-normalization-test"),
|
||||
lease_not_before=now - timedelta(seconds=1),
|
||||
lease_expires_at=now + timedelta(seconds=60),
|
||||
original_receipt_valid=True,
|
||||
original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
)
|
||||
|
||||
executor._validate_preflight(binding, job_id=JOB)
|
||||
|
||||
|
||||
def test_create_new_executor_resumes_executing_and_uses_absence_bound_writers() -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from aios_continuation_executor import (
|
||||
ContinuationCommit,
|
||||
ContinuationPermissions,
|
||||
ContinuationRuntimeBinding,
|
||||
)
|
||||
|
||||
new_oid = "b" * 40
|
||||
|
||||
class Store:
|
||||
def read(self, _job_id: str) -> dict[str, object]:
|
||||
return {"state": "EXECUTING", "revision": 4}
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.store = Store()
|
||||
self.advanced: list[str] = []
|
||||
|
||||
def advance(self, _job_id: str, state: JobState, **_kwargs: object) -> object:
|
||||
self.advanced.append(state.value)
|
||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||
|
||||
def complete_continuation_cycle(self, *_args: object, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
class Workspace:
|
||||
def commit_files(self, _files: object, _message: str) -> str:
|
||||
return new_oid
|
||||
|
||||
class CreateAuthority:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str, str]] = []
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return older == BASE and newer == new_oid
|
||||
|
||||
def create_task_branch_cas(self, branch: str, base: str, new: str) -> str:
|
||||
self.created.append((branch, base, new))
|
||||
return new
|
||||
|
||||
def compare_and_swap_branch(self, *_args: object) -> str:
|
||||
raise AssertionError("existing-branch CAS must not serve CREATE_NEW")
|
||||
|
||||
class CreateRemote:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str]] = []
|
||||
|
||||
def push_new_exact(self, branch: str, new: str) -> None:
|
||||
self.created.append((branch, new))
|
||||
|
||||
def push_exact(self, *_args: object) -> None:
|
||||
raise AssertionError("existing-branch push must not serve CREATE_NEW")
|
||||
|
||||
class Credential:
|
||||
def project(self) -> None: pass
|
||||
def revoke(self) -> None: pass
|
||||
|
||||
controller=Controller(); authority=CreateAuthority(); remote=CreateRemote()
|
||||
executor=ContinuationExecutor(controller,authority,Workspace(),remote,Credential())
|
||||
executor._validate_preflight=lambda *_args,**_kwargs: None
|
||||
executor._finish_remote_verification=lambda *_args,**_kwargs: {
|
||||
"job_id":JOB,"state":"COMPLETED","receipt":{"authority_oid":new_oid,"commit_visible":True}
|
||||
}
|
||||
now=datetime.now(timezone.utc)
|
||||
binding=ContinuationRuntimeBinding(
|
||||
request=lease(branch_mode="CREATE_NEW"),job_id=JOB,
|
||||
workspace_path=Path("/tmp/create-new-executor-resume"),
|
||||
lease_not_before=now-timedelta(seconds=1),lease_expires_at=now+timedelta(seconds=60),
|
||||
original_receipt_valid=True,original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
)
|
||||
result=executor.execute(
|
||||
binding,ContinuationCommit("create-new-test",{"docs/test.txt":b"ok"},"test")
|
||||
)
|
||||
|
||||
assert result["state"]=="COMPLETED"
|
||||
assert authority.created==[(BRANCH,BASE,new_oid)]
|
||||
assert remote.created==[(BRANCH,new_oid)]
|
||||
assert controller.advanced==["LOCAL_VERIFIED","REMOTE_SYNCING"]
|
||||
@@ -1,67 +0,0 @@
|
||||
from pathlib import Path
|
||||
import hashlib,json,os,tarfile,sys,tempfile
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
import aios_isolated_executor as mod
|
||||
|
||||
def make_archive(tmp_path):
|
||||
root=tmp_path/"src"; root.mkdir()
|
||||
(root/"hello.py").write_text("VALUE = 1\n")
|
||||
archive=tmp_path/"source.tgz"
|
||||
with tarfile.open(archive,"w:gz") as tf: tf.add(root/"hello.py",arcname="hello.py")
|
||||
return archive
|
||||
|
||||
def test_isolated_executor_applies_hash_bound_change(tmp_path,monkeypatch):
|
||||
archive=make_archive(tmp_path)
|
||||
monkeypatch.delenv("CREDENTIALS_DIRECTORY",raising=False)
|
||||
monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||
request={
|
||||
"schema":"aios.isolated-execution.request.v1",
|
||||
"request_id":"task-req-0000000000000000",
|
||||
"source_archive":str(archive),
|
||||
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||
"change_set":[{
|
||||
"path":"hello.py","operation":"replace_text","old":"VALUE = 1","new":"VALUE = 2","count":1,
|
||||
"expected_sha256":mod.sha256(b"VALUE = 1\n")
|
||||
}],
|
||||
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","hello.py"],"cwd":".","timeout_seconds":30}]
|
||||
}
|
||||
req=tmp_path/"req.json"; res=tmp_path/"res.json"
|
||||
req.write_text(json.dumps(request))
|
||||
mod.execute_request(req,res)
|
||||
out=json.loads(res.read_text())
|
||||
assert out["request_id"]=="task-req-0000000000000000"
|
||||
assert out["credential_isolation"]["credential_env_absent"] is True
|
||||
changed=out["changed_files"][0]
|
||||
assert changed["path"]=="hello.py" and changed["deleted"] is False
|
||||
|
||||
def test_isolated_executor_source_drift_denied(tmp_path,monkeypatch):
|
||||
archive=make_archive(tmp_path); monkeypatch.setattr(mod,"assert_uncredentialed",lambda:None)
|
||||
request={
|
||||
"schema":"aios.isolated-execution.request.v1","request_id":"task-req-0000000000000001",
|
||||
"source_archive":str(archive),
|
||||
"source_archive_sha256":mod.sha256(archive.read_bytes()),
|
||||
"change_set":[{"path":"hello.py","operation":"write_file","content":"x\n","expected_sha256":"0"*64}],
|
||||
"verification":[]
|
||||
}
|
||||
req=tmp_path/"req.json"; res=tmp_path/"res.json"; req.write_text(json.dumps(request))
|
||||
with pytest.raises(mod.IsolatedExecutorError,match="SOURCE_DRIFT"):
|
||||
mod.execute_request(req,res)
|
||||
|
||||
def test_credential_boundary_eacces_is_accepted(monkeypatch):
|
||||
class DeniedDirectory:
|
||||
def exists(self): return True
|
||||
def is_symlink(self): return False
|
||||
def is_dir(self): return True
|
||||
def iterdir(self): raise PermissionError(13,"permission denied")
|
||||
monkeypatch.setattr(mod.os,"access",lambda *_args,**_kwargs:False)
|
||||
assert mod._credential_exposure(DeniedDirectory()) is None
|
||||
|
||||
def test_systemd_unit_has_no_credentials_and_private_network():
|
||||
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
||||
assert "PrivateNetwork=true" in unit
|
||||
assert "InaccessiblePaths=/run/credentials" in unit
|
||||
assert "LoadCredential" not in unit
|
||||
assert "SupplementaryGroups=gitops" not in unit
|
||||
@@ -1,524 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
||||
BRIDGE_OPERATION_CONTRACTS,
|
||||
BridgeV2Adapter,
|
||||
)
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime # noqa: E402
|
||||
from aios_governed_task_bootstrap import BootstrapError, GovernedTaskBinding, GovernedTaskBootstrap, _task_key # noqa: E402
|
||||
from aios_online_agent_execution import ( # noqa: E402
|
||||
ExecutionProjectionError,
|
||||
GovernedExecutionBinding,
|
||||
OnlineTaskProjectionClient,
|
||||
project_execution_envelope,
|
||||
project_online_task,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
import aios_online_agent_worker as worker_module # noqa: E402
|
||||
from aios_online_agent_worker import ExecutionEnvelope, WorkerError, WorkerSupervisor, governed_commit, load_task_state # noqa: E402
|
||||
|
||||
|
||||
BASE = "a642b045775b9154a00ae78543037fbef4fb447b"
|
||||
TASK = "p0-claim-online-agent-auto-execution-20260817"
|
||||
JOB = "job-2031a9e5682a4238f98e3ac7aee8a593"
|
||||
BRANCH = "agent/p0-claim-online-agent-auto-execution-20260817"
|
||||
|
||||
|
||||
def binding() -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def task_without_envelope() -> dict:
|
||||
return {
|
||||
"task_id": TASK,
|
||||
"goal": "make governed Online Tasks automatically executable",
|
||||
"instructions": "project, claim, heartbeat, and preserve exact lineage",
|
||||
"required_capabilities": ["git.read", "git.test"],
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def test_project_online_task_builds_execution_metadata_from_binding() -> None:
|
||||
projected = project_online_task(binding(), task_without_envelope())
|
||||
execution = projected["metadata"]["execution"]
|
||||
assert execution["schema"] == "aios.online-agent.execution.v2"
|
||||
assert execution["job_id"] == JOB
|
||||
assert execution["task_id"] == TASK
|
||||
assert execution["repository"] == "aios/aios"
|
||||
assert execution["base_oid"] == BASE
|
||||
assert execution["expected_branch_oid"] == BASE
|
||||
assert execution["target_branch"] == BRANCH
|
||||
assert execution["generation"] == 1
|
||||
assert projected["metadata"]["execution_projection"]["execution_digest"] == execution["execution_digest"]
|
||||
|
||||
|
||||
def test_job_to_task_execution_projection(tmp_path: Path) -> None:
|
||||
exact = binding()
|
||||
durable = GovernedTaskBinding(
|
||||
task_id=exact.task_id,
|
||||
job_id=exact.job_id,
|
||||
repository=exact.repository,
|
||||
base_oid=exact.base_oid,
|
||||
expected_branch_oid=exact.expected_branch_oid,
|
||||
target_branch=exact.target_branch,
|
||||
branch_mode=exact.branch_mode,
|
||||
generation=exact.generation,
|
||||
idempotency_key=exact.idempotency_key,
|
||||
risk="engineering",
|
||||
description="automatic claim infrastructure",
|
||||
)
|
||||
|
||||
class Bootstrap:
|
||||
def create(self, _request: dict) -> dict:
|
||||
return durable.response()
|
||||
|
||||
def online_task_projection(self, _task_id: str, online: dict, execution: dict | None, **_kwargs: object) -> dict:
|
||||
return project_online_task(exact, online, execution)
|
||||
|
||||
def get_binding(self, _task_id: str) -> GovernedTaskBinding:
|
||||
return durable
|
||||
|
||||
def execution_binding(self, _binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||
return exact
|
||||
|
||||
class Projector:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def create(self, projected: dict, *, binding: GovernedExecutionBinding) -> dict:
|
||||
self.calls += 1
|
||||
assert binding == exact
|
||||
return {**projected, "status": "QUEUED"}
|
||||
|
||||
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||
runtime.bootstrap = Bootstrap()
|
||||
runtime.bindings = {}
|
||||
runtime.online_task_projector = Projector()
|
||||
runtime.projection_outbox_root = tmp_path / "projection-outbox"
|
||||
runtime.projection_outbox_root.mkdir()
|
||||
response = GovernedRuntime.create_task(
|
||||
runtime,
|
||||
{
|
||||
"task_id": TASK,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"risk": "engineering",
|
||||
"description": "automatic claim infrastructure",
|
||||
"idempotency_key": exact.idempotency_key,
|
||||
"online_task": task_without_envelope(),
|
||||
},
|
||||
)
|
||||
assert response["online_task_projection"]["status"] == "PROJECTED"
|
||||
assert response["online_task_projection"]["task_status"] == "QUEUED"
|
||||
assert runtime.online_task_projector.calls == 1
|
||||
|
||||
|
||||
def test_execution_envelope_schema_validation() -> None:
|
||||
envelope = project_execution_envelope(binding())
|
||||
validate_execution_envelope(envelope, binding=binding())
|
||||
malformed = dict(envelope)
|
||||
malformed["schema"] = "aios.online-agent.execution.v1"
|
||||
with pytest.raises(ExecutionProjectionError, match="EXECUTION_ENVELOPE_SCHEMA_VALIDATION"):
|
||||
validate_execution_envelope(malformed, binding=binding())
|
||||
|
||||
|
||||
def test_projection_response_must_equal_submitted_execution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
submitted = project_online_task(binding(), task_without_envelope(), {"action": "source_capture", "source_signals": ["submitted"]})
|
||||
returned = project_online_task(binding(), task_without_envelope(), {"action": "source_capture", "source_signals": ["different"]})
|
||||
returned.update({"status": "QUEUED"})
|
||||
|
||||
class Response:
|
||||
status = 201
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return json.dumps(returned).encode("utf-8")
|
||||
|
||||
monkeypatch.setattr("aios_online_agent_execution.urllib.request.urlopen", lambda *_args, **_kwargs: Response())
|
||||
token = tmp_path / "control.token"
|
||||
token.write_text("x" * 40, encoding="utf-8")
|
||||
with pytest.raises(ExecutionProjectionError, match="ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID"):
|
||||
OnlineTaskProjectionClient("http://127.0.0.1:1", token).create(submitted, binding=binding())
|
||||
|
||||
|
||||
class _ProjectedBridge:
|
||||
def __init__(self, value: dict) -> None:
|
||||
self.value = value
|
||||
self.calls = 0
|
||||
|
||||
def project_execution(self, _task: dict, _execution: dict | None = None) -> tuple[int, dict]:
|
||||
self.calls += 1
|
||||
return 200, self.value
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, tasks: list[dict]) -> None:
|
||||
self.tasks = tasks
|
||||
self.claimed: list[str] = []
|
||||
|
||||
def poll(self) -> list[dict]:
|
||||
return self.tasks
|
||||
|
||||
def claim(self, task_id: str) -> tuple[int, dict]:
|
||||
self.claimed.append(task_id)
|
||||
return 200, {"task_id": task_id, "lease_token": "x" * 32, "attempt": 1, "revision": 2}
|
||||
|
||||
|
||||
def test_task_visible_to_worker_claimable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||
envelope, source = supervisor.resolve_envelope(task_without_envelope())
|
||||
assert source == "recovered_projection"
|
||||
assert envelope.task_id == TASK
|
||||
assert envelope.job_id == JOB
|
||||
assert envelope.action == "source_capture"
|
||||
|
||||
|
||||
def test_task_claim_after_projection(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||
fake_agent = _FakeAgent([task_without_envelope()])
|
||||
supervisor.agent = fake_agent
|
||||
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||
processed: list[tuple[str, str]] = []
|
||||
supervisor.process_claim = lambda task, claim, envelope: processed.append((task["task_id"], envelope.execution_digest if hasattr(envelope, "execution_digest") else envelope.digest)) # type: ignore[method-assign]
|
||||
assert supervisor.once() == 0
|
||||
assert fake_agent.claimed == [TASK]
|
||||
assert processed and processed[0][0] == TASK
|
||||
|
||||
|
||||
def test_claim_heartbeat_source_capture_and_result_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
execution = project_execution_envelope(binding(), {"action": "source_capture", "source_signals": ["agent"]})
|
||||
task = task_without_envelope()
|
||||
task["metadata"] = {"execution": execution}
|
||||
|
||||
class Agent:
|
||||
def __init__(self) -> None:
|
||||
self.heartbeats: list[str] = []
|
||||
self.results: list[dict] = []
|
||||
|
||||
def heartbeat(self, _task_id: str, _lease_token: str, stage: str) -> tuple[int, dict]:
|
||||
self.heartbeats.append(stage)
|
||||
return 200, {}
|
||||
|
||||
def get(self, _task_id: str) -> tuple[int, dict]:
|
||||
return 200, task
|
||||
|
||||
def result(self, _task_id: str, _lease_token: str, _key: str, result: dict) -> tuple[int, dict]:
|
||||
self.results.append(result)
|
||||
return 200, {}
|
||||
|
||||
worktree = tmp_path / "worktree"
|
||||
source = worktree / "active-runtime" / "app" / "agent_auto_fixture.py"
|
||||
source.parent.mkdir(parents=True)
|
||||
source.write_text("VALUE = 1\n", encoding="utf-8")
|
||||
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path / "state")
|
||||
monkeypatch.setattr(worker_module, "new_run_root", lambda: tmp_path / "run")
|
||||
monkeypatch.setattr(worker_module, "prepare_worktree", lambda *_args, **_kwargs: worktree)
|
||||
monkeypatch.setattr(worker_module, "cleanup_run_root", lambda *_args, **_kwargs: None)
|
||||
agent = Agent()
|
||||
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||
supervisor.agent = agent
|
||||
supervisor.bridge = _ProjectedBridge(execution)
|
||||
envelope, _ = supervisor.resolve_envelope(task)
|
||||
supervisor.process_claim(task, {"task_id": TASK, "lease_token": "l" * 32, "attempt": 2, "revision": 8}, envelope)
|
||||
assert agent.heartbeats and agent.heartbeats[0] == "claimed"
|
||||
assert len(agent.results) == 1
|
||||
assert agent.results[0]["status"] == "SUCCEEDED"
|
||||
assert agent.results[0]["classification"] == "NEEDS_REWORK_SOURCE_CAPTURE"
|
||||
|
||||
|
||||
def test_dynamic_task_binding_no_hardcoded_task_id() -> None:
|
||||
worker = (APP / "aios_online_agent_worker.py").read_text(encoding="utf-8")
|
||||
assert TASK not in worker
|
||||
assert BASE not in worker
|
||||
for name in ("aios-online-agent-worker.service", "aios-online-engineering-consumer.service"):
|
||||
unit = (ROOT / "deploy" / "systemd" / name).read_text(encoding="utf-8")
|
||||
assert TASK not in unit
|
||||
assert JOB not in unit
|
||||
assert BASE not in unit
|
||||
assert BRANCH not in unit
|
||||
assert "AIOS_ENGINEERING_PLAN_FILE" not in unit
|
||||
assert "aios_online_agent_worker.py" in unit
|
||||
projection = (ROOT / "deploy" / "systemd" / "aios-chatgpt-bridge-v2-agent-task-projection.conf").read_text(encoding="utf-8")
|
||||
assert "AIOS_ONLINE_TASK_PROJECTION_ENABLED=1" in projection
|
||||
assert "LoadCredential=agent_task_control_token:" in projection
|
||||
assert "LoadCredentialEncrypted=" not in projection
|
||||
assert "Bearer " not in projection
|
||||
|
||||
|
||||
def test_durable_task_key_has_collision_resistant_exact_identity() -> None:
|
||||
assert _task_key("task:a") != _task_key("task-a")
|
||||
assert _task_key("x" * 140 + "a") != _task_key("x" * 140 + "b")
|
||||
|
||||
|
||||
def test_existing_binding_rejects_idempotency_head_and_generation_drift(tmp_path: Path) -> None:
|
||||
class Authority:
|
||||
@staticmethod
|
||||
def commit_exists(_oid: str) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def read_ref(_ref: str) -> None:
|
||||
return None
|
||||
|
||||
bootstrap = GovernedTaskBootstrap(Authority(), tmp_path, remote_name="origin", token_path=tmp_path / "token", ssh_key_path=tmp_path / "key")
|
||||
request = {
|
||||
"task_id": TASK,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"risk": "engineering",
|
||||
"description": "automatic claim infrastructure",
|
||||
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||
"expected_branch_oid": BASE,
|
||||
"generation": 1,
|
||||
}
|
||||
bootstrap.create(request)
|
||||
for drift in (
|
||||
{"idempotency_key": "p0-claim-online-agent-auto-execution-v2"},
|
||||
{"expected_branch_oid": "b" * 40},
|
||||
{"generation": 2},
|
||||
):
|
||||
with pytest.raises(BootstrapError, match="TASK_BINDING_CONFLICT"):
|
||||
bootstrap.create({**request, **drift})
|
||||
|
||||
|
||||
def test_existing_branch_without_durable_parent_ownership_is_denied(tmp_path: Path) -> None:
|
||||
class Authority:
|
||||
@staticmethod
|
||||
def commit_exists(_oid: str) -> bool:
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def read_ref(_ref: str) -> str:
|
||||
return BASE
|
||||
|
||||
bootstrap = GovernedTaskBootstrap(Authority(), tmp_path, remote_name="origin", token_path=tmp_path / "token", ssh_key_path=tmp_path / "key")
|
||||
with pytest.raises(BootstrapError, match="EXISTING_BRANCH_OWNERSHIP_REQUIRED"):
|
||||
bootstrap.create({
|
||||
"task_id": TASK,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"risk": "engineering",
|
||||
"description": "automatic claim infrastructure",
|
||||
"idempotency_key": "p0-claim-online-agent-auto-execution-v1",
|
||||
"expected_branch_oid": BASE,
|
||||
"generation": 2,
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "error"),
|
||||
[
|
||||
("repository", "other/repo", "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||
("target_branch", "agent/wrong-branch", "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||
("generation", 2, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||
],
|
||||
)
|
||||
def test_wrong_lineage_envelope_deny(field: str, value: object, error: str) -> None:
|
||||
envelope = project_execution_envelope(binding())
|
||||
envelope[field] = value
|
||||
with pytest.raises(ExecutionProjectionError, match=error):
|
||||
validate_execution_envelope(envelope, binding=binding())
|
||||
|
||||
|
||||
def test_capability_mismatch_deny() -> None:
|
||||
envelope = project_execution_envelope(
|
||||
binding(),
|
||||
{"action": "source_capture", "capability_scope": ["git.read", "governed.write.file"]},
|
||||
)
|
||||
with pytest.raises(ExecutionProjectionError, match="CAPABILITY_MISMATCH_DENY"):
|
||||
validate_execution_envelope(envelope, binding=binding(), worker_capabilities=["git.read"])
|
||||
|
||||
|
||||
def test_governance_lineage_override_deny() -> None:
|
||||
envelope = project_execution_envelope(binding())
|
||||
envelope["governance"] = {**envelope["governance"], "job_id": "job-wrong-lineage"}
|
||||
with pytest.raises(ExecutionProjectionError, match="JOB_BINDING_CONFLICT"):
|
||||
validate_execution_envelope(envelope, binding=binding())
|
||||
|
||||
|
||||
def test_malformed_persisted_envelope_does_not_fallback() -> None:
|
||||
task = task_without_envelope()
|
||||
task["metadata"] = {"execution": {"schema": "broken"}}
|
||||
bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||
supervisor.bridge = bridge
|
||||
with pytest.raises(WorkerError, match="EXECUTION_ENVELOPE_SCHEMA_VALIDATION"):
|
||||
supervisor.resolve_envelope(task)
|
||||
assert bridge.calls == 0
|
||||
|
||||
|
||||
def test_self_consistent_persisted_wrong_lineage_is_denied_by_authority() -> None:
|
||||
task = task_without_envelope()
|
||||
wrong = binding()
|
||||
wrong = GovernedExecutionBinding.from_mapping({**wrong.public(), "job_id": "job-00000000000000000000000000000000"})
|
||||
task["metadata"] = {"execution": project_execution_envelope(wrong)}
|
||||
supervisor = WorkerSupervisor("runtime-token-not-secret")
|
||||
supervisor.bridge = _ProjectedBridge(project_execution_envelope(binding()))
|
||||
with pytest.raises(WorkerError, match="TASK_PERSISTED_BINDING_CONFLICT"):
|
||||
supervisor.resolve_envelope(task)
|
||||
|
||||
|
||||
def test_projection_control_missing_denies_before_governed_side_effect() -> None:
|
||||
class Bootstrap:
|
||||
called = False
|
||||
|
||||
def create(self, _request: dict) -> dict:
|
||||
self.called = True
|
||||
raise AssertionError("must not create governed state")
|
||||
|
||||
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||
runtime.bootstrap = Bootstrap()
|
||||
runtime.online_task_projector = None
|
||||
with pytest.raises(RuntimeError, match="ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE"):
|
||||
GovernedRuntime.create_task(runtime, {"task_id": TASK, "require_online_task_projection": True})
|
||||
assert runtime.bootstrap.called is False
|
||||
|
||||
|
||||
def test_source_capture_projection_cannot_write() -> None:
|
||||
envelope = project_execution_envelope(binding())
|
||||
assert envelope["action"] == "source_capture"
|
||||
assert envelope["change_set"] == []
|
||||
assert envelope["verification"] == []
|
||||
assert envelope["governance"]["writer_lease"] is False
|
||||
assert all(envelope["governance"][field] is False for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"))
|
||||
|
||||
|
||||
def test_source_capture_state_allows_next_attempt_deterministic_digest(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(worker_module, "STATE_ROOT", tmp_path)
|
||||
state_path = tmp_path / "tasks" / f"{TASK}.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.write_text(json.dumps({"task_id": TASK, "execution_digest": "a" * 64, "phase": "source_capture_done"}), encoding="utf-8")
|
||||
assert load_task_state(TASK, "b" * 64) is None
|
||||
|
||||
|
||||
def test_bridge_exposes_controlled_projection_operation() -> None:
|
||||
seen: list[dict] = []
|
||||
adapter = BridgeV2Adapter(
|
||||
runtime_status={},
|
||||
handlers={"projectOnlineAgentExecution": lambda request: seen.append(dict(request)) or project_execution_envelope(binding())},
|
||||
)
|
||||
response = adapter.projectOnlineAgentExecution({"task_id": TASK})
|
||||
assert response["job_id"] == JOB
|
||||
assert seen == [{"task_id": TASK}]
|
||||
assert BRIDGE_OPERATION_CONTRACTS["projectOnlineAgentExecution"]["path"] == "/v2/execution-envelope"
|
||||
|
||||
|
||||
def test_execution_digest_is_canonical() -> None:
|
||||
envelope = project_execution_envelope(binding())
|
||||
material = {key: value for key, value in envelope.items() if key != "execution_digest"}
|
||||
expected = hashlib.sha256(json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
assert envelope["execution_digest"] == expected
|
||||
|
||||
|
||||
def test_governed_commit_uses_existing_exact_job_without_recreating(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
execution = project_execution_envelope(
|
||||
binding(),
|
||||
{
|
||||
"action": "deterministic",
|
||||
"change_set": [{"operation": "write_file", "path": "result.txt", "content": "ok\n"}],
|
||||
"governance": {"allowed_files": ["result.txt"]},
|
||||
},
|
||||
)
|
||||
envelope = ExecutionEnvelope.from_task({"task_id": TASK, "metadata": {"execution": execution}})
|
||||
(tmp_path / "result.txt").write_text("ok\n", encoding="utf-8")
|
||||
commit_oid = "b" * 40
|
||||
|
||||
class Bridge:
|
||||
def __init__(self) -> None:
|
||||
self.routes: list[str] = []
|
||||
|
||||
def call(self, route: str, body: dict) -> tuple[int, dict]:
|
||||
self.routes.append(route)
|
||||
if route == "/v2/job":
|
||||
assert body == {"job_id": JOB}
|
||||
else:
|
||||
assert body["task_id"] == TASK
|
||||
assert body["job_id"] == JOB
|
||||
if route == "/v2/writer-lease":
|
||||
return 200, {
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
"target_branch": BRANCH,
|
||||
}
|
||||
if route == "/v2/commit-files":
|
||||
return 200, {
|
||||
"job_id": JOB,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"commit_oid": commit_oid,
|
||||
"commit_visible": True,
|
||||
"receipt": {
|
||||
"remote_verified": True,
|
||||
"authority_oid": commit_oid,
|
||||
"api_branch_oid": commit_oid,
|
||||
"git_remote_oid": commit_oid,
|
||||
},
|
||||
}
|
||||
if route == "/v2/job":
|
||||
return 200, {
|
||||
"state": "COMPLETED",
|
||||
"receipt": {
|
||||
"remote_verified": True,
|
||||
"authority_oid": commit_oid,
|
||||
"api_branch_oid": commit_oid,
|
||||
"git_remote_oid": commit_oid,
|
||||
},
|
||||
"branch_leases": [{
|
||||
"job_id": JOB,
|
||||
"branch": BRANCH,
|
||||
"state": "ACTIVE",
|
||||
"generation": 1,
|
||||
"current_head_oid": commit_oid,
|
||||
}],
|
||||
}
|
||||
raise AssertionError(route)
|
||||
|
||||
bridge = Bridge()
|
||||
monkeypatch.setattr(worker_module.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout=commit_oid + "\n", stderr=""))
|
||||
heartbeat = SimpleNamespace(ensure=lambda: None)
|
||||
result = governed_commit(envelope, tmp_path, bridge, heartbeat)
|
||||
assert bridge.routes == ["/v2/writer-lease", "/v2/commit-files", "/v2/job"]
|
||||
assert result["job_id"] == JOB
|
||||
assert result["commit_oid"] == commit_oid
|
||||
@@ -1,60 +0,0 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
||||
|
||||
from aios_online_agent_execution import GovernedExecutionBinding
|
||||
from aios_online_agent_plan_store import ExecutionPlanStore, ExecutionPlanError
|
||||
|
||||
BASE="a642b045775b9154a00ae78543037fbef4fb447b"
|
||||
HEAD="71523aef6e48b1e2fa516c456ca189b7a039104b"
|
||||
|
||||
def binding(head=HEAD):
|
||||
return GovernedExecutionBinding.from_mapping({
|
||||
"task_id":"task-plan-a","job_id":"job-plan-a","repository":"aios/aios","base_oid":BASE,
|
||||
"expected_branch_oid":head,"target_branch":"agent/task-plan-a","branch_mode":"CONTINUE_EXISTING",
|
||||
"generation":1,"idempotency_key":"governed-plan-a",
|
||||
})
|
||||
|
||||
def request():
|
||||
return {
|
||||
"schema":"aios.online-agent.plan.v1","task_id":"task-plan-a","job_id":"job-plan-a",
|
||||
"source_head_oid":HEAD,"generation":1,"idempotency_key":"plan-task-a-v1",
|
||||
"execution":{
|
||||
"action":"deterministic",
|
||||
"change_set":[{"path":"x.py","operation":"write_file","content":"x=1\n"}],
|
||||
"verification":[{"label":"compile","argv":["/usr/bin/python3","-m","py_compile","x.py"],"cwd":".","timeout_seconds":30}],
|
||||
"capability_scope":["git.read","git.test"],
|
||||
}
|
||||
}
|
||||
|
||||
def test_plan_register_and_resolve(tmp_path):
|
||||
store=ExecutionPlanStore(tmp_path)
|
||||
created=store.register(binding(),request())
|
||||
assert created["idempotent"] is False
|
||||
resolved=store.resolve(binding())
|
||||
assert resolved["plan_digest"]==created["plan_digest"]
|
||||
assert resolved["execution"]["action"]=="deterministic"
|
||||
|
||||
def test_plan_is_idempotent(tmp_path):
|
||||
store=ExecutionPlanStore(tmp_path); req=request()
|
||||
first=store.register(binding(),req); second=store.register(binding(),req)
|
||||
assert second["idempotent"] is True
|
||||
assert first["plan_digest"]==second["plan_digest"]
|
||||
|
||||
def test_plan_source_head_drift_denied(tmp_path):
|
||||
store=ExecutionPlanStore(tmp_path); store.register(binding(),request())
|
||||
with pytest.raises(ExecutionPlanError,match="SOURCE_HEAD_DRIFT"):
|
||||
store.resolve(binding("b"*40))
|
||||
|
||||
def test_plan_replacement_requires_cas(tmp_path):
|
||||
store=ExecutionPlanStore(tmp_path); first=store.register(binding(),request())
|
||||
changed=request(); changed["idempotency_key"]="plan-task-a-v2"
|
||||
changed["execution"]["change_set"][0]["content"]="x=2\n"
|
||||
with pytest.raises(ExecutionPlanError,match="REPLACEMENT_CAS_MISMATCH"):
|
||||
store.register(binding(),changed)
|
||||
changed["expected_previous_plan_digest"]=first["plan_digest"]
|
||||
second=store.register(binding(),changed)
|
||||
assert second["plan_digest"]!=first["plan_digest"]
|
||||
@@ -1,245 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
APP=ROOT/"active-runtime/app"
|
||||
sys.path.insert(0,str(APP))
|
||||
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
from aios_governed_task_bootstrap import GovernedTaskBinding, GovernedTaskBootstrap
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState, JobStore
|
||||
from aios_continuation_executor import ContinuationExecutionError, DetachedGitWorkspace
|
||||
from aios_online_agent_worker import (
|
||||
BridgeClient,
|
||||
IsolatedDeterministicExecutor,
|
||||
load_task_state,
|
||||
_claim_order,
|
||||
_isolated_executor_wait_seconds,
|
||||
_verify_converged_job,
|
||||
WorkerError,
|
||||
)
|
||||
|
||||
|
||||
BASE="a642b045775b9154a00ae78543037fbef4fb447b"
|
||||
HEAD="71523aef6e48b1e2fa516c456ca189b7a039104b"
|
||||
TASK="p0-claim-online-agent-auto-execution-20260817"
|
||||
JOB="job-2031a9e5682a4238f98e3ac7aee8a593"
|
||||
BRANCH="agent/p0-claim-online-agent-auto-execution-20260817"
|
||||
|
||||
|
||||
def test_source_signals_are_bounded_by_projection():
|
||||
binding=GovernedExecutionBinding.from_mapping({
|
||||
"task_id":TASK,"job_id":JOB,"repository":"aios/aios","base_oid":BASE,
|
||||
"expected_branch_oid":BASE,"target_branch":BRANCH,"branch_mode":"CREATE_NEW",
|
||||
"generation":1,"idempotency_key":"p0-claim-online-agent-auto-execution-a642b04-v1",
|
||||
})
|
||||
execution=project_execution_envelope(binding,{
|
||||
"action":"source_capture",
|
||||
"source_signals":["x"*5000,"x"*5000,"trace credential tool "*100],
|
||||
})
|
||||
assert len(execution["source_signals"]) <= 16
|
||||
assert all(0 < len(item) <= 512 for item in execution["source_signals"])
|
||||
|
||||
|
||||
def test_same_job_create_new_binding_normalizes_to_current_head():
|
||||
binding=GovernedTaskBinding(
|
||||
task_id=TASK,job_id=JOB,repository="aios/aios",base_oid=BASE,
|
||||
expected_branch_oid=BASE,target_branch=BRANCH,branch_mode="CREATE_NEW",
|
||||
generation=1,idempotency_key="p0-claim-online-agent-auto-execution-a642b04-v1",
|
||||
risk="engineering",description="automatic claim",
|
||||
)
|
||||
bootstrap=GovernedTaskBootstrap.__new__(GovernedTaskBootstrap)
|
||||
bootstrap.authority=SimpleNamespace(read_ref=lambda ref: HEAD)
|
||||
class Connection:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self,*a): return False
|
||||
def execute(self,*a,**k):
|
||||
return SimpleNamespace(fetchall=lambda:[{
|
||||
"current_head_oid":HEAD,"generation":1,"state":"ACTIVE","lineage_anchor_oid":BASE
|
||||
}])
|
||||
bootstrap._store=lambda _task:(SimpleNamespace(_connect=lambda:Connection()),None)
|
||||
normalized=bootstrap.execution_binding(binding)
|
||||
assert normalized.base_oid == BASE
|
||||
assert normalized.expected_branch_oid == HEAD
|
||||
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
||||
assert normalized.generation == 1
|
||||
|
||||
|
||||
def test_recovery_receipt_requires_exact_oid_and_active_lease():
|
||||
envelope=SimpleNamespace(job_id=JOB,target_branch=BRANCH,generation=1)
|
||||
response={
|
||||
"state":"COMPLETED",
|
||||
"receipt":{"remote_verified":True,"authority_oid":HEAD,"api_branch_oid":HEAD,"git_remote_oid":HEAD},
|
||||
"branch_leases":[{
|
||||
"job_id":JOB,"branch":BRANCH,"state":"ACTIVE","generation":1,"current_head_oid":HEAD
|
||||
}],
|
||||
}
|
||||
_verify_converged_job(response,envelope,HEAD)
|
||||
bad={**response,"receipt":{**response["receipt"],"git_remote_oid":BASE}}
|
||||
with pytest.raises(WorkerError,match="GOVERNED_RECEIPT_OID_MISMATCH"):
|
||||
_verify_converged_job(bad,envelope,HEAD)
|
||||
|
||||
|
||||
def test_executor_unit_is_uncredentialed():
|
||||
unit=(ROOT/"deploy/systemd/aios-online-agent-isolated-executor.service").read_text()
|
||||
assert "PrivateNetwork=true" in unit
|
||||
assert "RestrictAddressFamilies=AF_UNIX AF_INET" in unit
|
||||
assert "IPAddressDeny=any" in unit
|
||||
assert "IPAddressAllow=localhost" in unit
|
||||
assert "InaccessiblePaths=/run/credentials" in unit
|
||||
assert "LoadCredential" not in unit
|
||||
assert "SupplementaryGroups=gitops" not in unit
|
||||
|
||||
|
||||
def test_isolated_executor_wait_budget_includes_safety_margin():
|
||||
assert _isolated_executor_wait_seconds(({"timeout_seconds":1200},)) == 1230
|
||||
|
||||
|
||||
def test_prepared_deterministic_plan_precedes_source_capture_at_same_priority():
|
||||
deterministic=SimpleNamespace(action="deterministic",task_id="task-deterministic")
|
||||
source_capture=SimpleNamespace(action="source_capture",task_id="task-source-capture")
|
||||
task={"priority":100,"created_at":"2026-08-17T00:00:00Z"}
|
||||
ordered=sorted(((task,source_capture),(task,deterministic)),key=_claim_order)
|
||||
assert ordered[0][1] is deterministic
|
||||
|
||||
|
||||
def test_plan_registry_gets_precedence_when_task_has_no_execution(monkeypatch):
|
||||
client=BridgeClient("agent-token-placeholder")
|
||||
seen=[]
|
||||
monkeypatch.setattr(client,"call",lambda route,body:(seen.append((route,body)) or (200,{})))
|
||||
task={"task_id":TASK,"required_capabilities":["git.read"]}
|
||||
client.project_execution(task)
|
||||
assert seen == [("/v2/execution-envelope",{"task_id":TASK,"required_capabilities":["git.read"]})]
|
||||
client.project_execution(task,{"action":"source_capture","source_signals":["trace"]})
|
||||
assert seen[-1][1]["execution"]["action"] == "source_capture"
|
||||
|
||||
|
||||
def test_executor_exchange_files_are_group_readable_and_directories_setgid(tmp_path):
|
||||
request=tmp_path/"request.json"
|
||||
IsolatedDeterministicExecutor._atomic(request,b"{}")
|
||||
assert request.stat().st_mode & 0o777 == 0o640
|
||||
tmpfiles=(ROOT/"deploy/tmpfiles.d/aios-online-agent-executor.conf").read_text()
|
||||
assert tmpfiles.count(" 2770 ") == 5
|
||||
|
||||
|
||||
def test_precommit_state_recovery_requires_a_later_official_attempt(monkeypatch,tmp_path):
|
||||
import aios_online_agent_worker as worker_module
|
||||
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.write_text(json.dumps({"task_id":TASK,"execution_digest":"a"*64,"phase":"workspace_prepared"}))
|
||||
assert load_task_state(TASK,"b"*64) is None
|
||||
state_path.write_text(json.dumps({"task_id":TASK,"execution_digest":"a"*64,"phase":"verification_passed","attempt":16}))
|
||||
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||
load_task_state(TASK,"b"*64,attempt=16)
|
||||
assert load_task_state(TASK,"b"*64,attempt=17) is None
|
||||
|
||||
|
||||
def test_reconciliation_state_recovery_requires_later_attempt_and_same_job(monkeypatch,tmp_path):
|
||||
import aios_online_agent_worker as worker_module
|
||||
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.write_text(json.dumps({
|
||||
"task_id":TASK,
|
||||
"execution_digest":"a"*64,
|
||||
"phase":"reconciliation_required",
|
||||
"attempt":18,
|
||||
"job_id":JOB,
|
||||
"details":{"api_status":503,"error":"WORKTREE_DIRTY_SCOPE_MISMATCH"},
|
||||
}))
|
||||
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||
load_task_state(TASK,"b"*64,attempt=18,continuation_job_id=JOB,continuation_head_oid=HEAD)
|
||||
assert load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid=HEAD) is None
|
||||
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||
load_task_state(TASK,"b"*64,attempt=19,continuation_job_id="job-other",continuation_head_oid=HEAD)
|
||||
|
||||
|
||||
def test_legacy_dirty_scope_reconciliation_state_is_exactly_bounded(monkeypatch,tmp_path):
|
||||
import aios_online_agent_worker as worker_module
|
||||
monkeypatch.setattr(worker_module,"STATE_ROOT",tmp_path)
|
||||
state_path=tmp_path/"tasks"/f"{TASK}.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.write_text(json.dumps({
|
||||
"task_id":TASK,
|
||||
"execution_digest":"a"*64,
|
||||
"phase":"reconciliation_required",
|
||||
"job_id":JOB,
|
||||
"details":{"api_status":503,"error":"WORKTREE_DIRTY_SCOPE_MISMATCH"},
|
||||
}))
|
||||
assert load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid=HEAD) is None
|
||||
with pytest.raises(WorkerError,match="TASK_PAYLOAD_DRIFT"):
|
||||
load_task_state(TASK,"b"*64,attempt=19,continuation_job_id=JOB,continuation_head_oid="not-an-oid")
|
||||
|
||||
|
||||
def test_completed_same_job_begins_one_idempotent_continuation_cycle(tmp_path):
|
||||
controller=GitOpsJobController(JobStore(tmp_path/"jobs",payload_root=tmp_path/"payloads"))
|
||||
submitted=controller.submit({
|
||||
"idempotency_key":"same-job-continuation-test",
|
||||
"campaign_id":TASK,
|
||||
"task_id":TASK,
|
||||
"repository":"aios/aios",
|
||||
"operation":"GOVERNED_TASK_CREATE",
|
||||
"branch":BRANCH,
|
||||
"expected_branch_oid":HEAD,
|
||||
"payload_digest":"a"*64,
|
||||
})
|
||||
job_id=submitted.job_id
|
||||
controller.advance(job_id,JobState.AUTHORIZED,side_effect_state="AUTHORIZED",expected_revision=0)
|
||||
controller.lease_job(
|
||||
job_id,
|
||||
lease_id="lease-"+job_id,
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=HEAD,
|
||||
generation=1,
|
||||
lease_expires_at="2099-01-01T00:00:00+00:00",
|
||||
expected_revision=1,
|
||||
)
|
||||
controller.advance(job_id,JobState.WORKTREE_READY,side_effect_state="WORKTREE_READY",expected_revision=2)
|
||||
controller.advance(job_id,JobState.EXECUTING,side_effect_state="IN_PROGRESS",expected_revision=3)
|
||||
controller.advance(job_id,JobState.LOCAL_VERIFIED,side_effect_state="LOCAL_VERIFIED",receipt={"authority_oid":HEAD},expected_revision=4)
|
||||
controller.advance(job_id,JobState.REMOTE_SYNCING,side_effect_state="IN_PROGRESS",expected_revision=5)
|
||||
receipt={
|
||||
"remote_verified":True,
|
||||
"commit_visible":True,
|
||||
"authority_oid":HEAD,
|
||||
"api_branch_oid":HEAD,
|
||||
"git_remote_oid":HEAD,
|
||||
}
|
||||
controller.advance(job_id,JobState.REMOTE_VERIFIED,side_effect_state="REMOTE_VERIFIED",receipt=receipt,expected_revision=6)
|
||||
controller.advance(job_id,JobState.COMPLETED,side_effect_state="REMOTE_VERIFIED",receipt=receipt,expected_revision=7)
|
||||
first=controller.begin_continuation_cycle(
|
||||
job_id,
|
||||
idempotency_key="same-job-continuation-commit-v1",
|
||||
expected_head_oid=HEAD,
|
||||
generation=1,
|
||||
expected_revision=8,
|
||||
)
|
||||
assert first == {"completed":False,"commit_oid":None,"idempotent":False}
|
||||
assert controller.store.read(job_id)["state"] == "LEASED"
|
||||
replay=controller.begin_continuation_cycle(
|
||||
job_id,
|
||||
idempotency_key="same-job-continuation-commit-v1",
|
||||
expected_head_oid=HEAD,
|
||||
generation=1,
|
||||
expected_revision=8,
|
||||
)
|
||||
assert replay == {"completed":False,"commit_oid":None,"idempotent":True}
|
||||
|
||||
|
||||
def test_deterministic_materialization_recovers_only_an_in_scope_dirty_subset(monkeypatch,tmp_path):
|
||||
workspace=object.__new__(DetachedGitWorkspace)
|
||||
workspace.path=tmp_path
|
||||
monkeypatch.setattr(DetachedGitWorkspace,"_dirty_paths",lambda _self:{"old.py"})
|
||||
workspace.materialize_files({"old.py":b"new-old\n","new.py":b"new\n"})
|
||||
assert (tmp_path/"old.py").read_bytes() == b"new-old\n"
|
||||
assert (tmp_path/"new.py").read_bytes() == b"new\n"
|
||||
monkeypatch.setattr(DetachedGitWorkspace,"_dirty_paths",lambda _self:{"outside.py"})
|
||||
with pytest.raises(ContinuationExecutionError,match="WORKTREE_DIRTY_SCOPE_MISMATCH"):
|
||||
workspace.materialize_files({"old.py":b"again\n"})
|
||||
@@ -1,763 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
from aios_continuation_executor import ContinuationPermissions, ContinuationRuntimeBinding
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobStore
|
||||
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||
|
||||
|
||||
TASK_ID = "gov-writer-lease-recovery-test"
|
||||
BRANCH = "agent/writer-lease-recovery-test"
|
||||
BASE = "a" * 40
|
||||
JOB_ID_PREFIX = "writer-lease-runtime-lineage"
|
||||
|
||||
|
||||
class LocalAuthority:
|
||||
def __init__(self, head: str | None = None) -> None:
|
||||
self.head = head
|
||||
|
||||
def read_ref(self, _ref: str) -> str | None:
|
||||
return self.head
|
||||
|
||||
def commit_exists(self, _oid: str) -> bool:
|
||||
return True
|
||||
|
||||
def ancestry_check(self, _older: str, _newer: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def writer_request(task_id: str = TASK_ID, **overrides: object) -> dict[str, object]:
|
||||
request: dict[str, object] = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
request.update(overrides)
|
||||
return request
|
||||
|
||||
|
||||
def make_runtime(tmp_path: Path, task_id: str = TASK_ID, *, authority_head: str | None = None) -> GovernedRuntime:
|
||||
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||
runtime.config = SimpleNamespace(repository="aios/aios", state_root=tmp_path)
|
||||
runtime.authority = LocalAuthority(authority_head)
|
||||
runtime.bindings = {
|
||||
task_id: {
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
}
|
||||
}
|
||||
runtime.runtimes = {}
|
||||
runtime.controllers = {}
|
||||
runtime.bootstrap = GovernedTaskBootstrap(
|
||||
runtime.authority,
|
||||
tmp_path,
|
||||
remote_name="test",
|
||||
token_path=tmp_path / "token",
|
||||
ssh_key_path=tmp_path / "key",
|
||||
)
|
||||
runtime.bindings = {}
|
||||
runtime.create_task(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"risk": "engineering",
|
||||
"description": "governed runtime lineage test",
|
||||
"idempotency_key": JOB_ID_PREFIX + "-" + task_id,
|
||||
}
|
||||
)
|
||||
job_root = runtime._job_root(task_id)
|
||||
runtime.controllers[task_id] = GitOpsJobController(
|
||||
JobStore(job_root, payload_root=job_root / "payloads")
|
||||
)
|
||||
|
||||
def build(
|
||||
lease: WriterLeaseRequestV2,
|
||||
*,
|
||||
job_id: str,
|
||||
lease_not_before: datetime | None = None,
|
||||
lease_expires_at: datetime | None = None,
|
||||
) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||
controller = runtime.controllers.get(lease.task_id)
|
||||
if controller is None:
|
||||
root = runtime._job_root(lease.task_id)
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
now = datetime.now(timezone.utc)
|
||||
return (
|
||||
ContinuationRuntimeBinding(
|
||||
request=lease,
|
||||
job_id=job_id,
|
||||
workspace_path=tmp_path / "worktrees" / lease.task_id,
|
||||
lease_not_before=lease_not_before or now - timedelta(seconds=1),
|
||||
lease_expires_at=lease_expires_at or now + timedelta(seconds=lease.lease_seconds),
|
||||
original_receipt_valid=True,
|
||||
original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
),
|
||||
controller,
|
||||
object(),
|
||||
)
|
||||
|
||||
runtime._build_runtime = build
|
||||
return runtime
|
||||
|
||||
|
||||
def create_durable_lease(
|
||||
runtime: GovernedRuntime,
|
||||
*,
|
||||
expires_at: str = "2020-01-01T00:00:00+00:00",
|
||||
) -> GitOpsJobController:
|
||||
root = runtime._job_root(TASK_ID)
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
receipt = SimpleNamespace(job_id=runtime.bindings[TASK_ID]["job_id"])
|
||||
controller.lease_job(
|
||||
receipt.job_id,
|
||||
lease_id="writer-lease-recovery",
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
lease_expires_at=expires_at,
|
||||
expected_revision=0,
|
||||
)
|
||||
runtime.controllers[TASK_ID] = controller
|
||||
return controller
|
||||
|
||||
|
||||
def durable_row(runtime: GovernedRuntime) -> tuple[str, int]:
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT lease_expires_at, revision FROM branch_leases WHERE lease_id=?",
|
||||
("writer-lease-recovery",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def test_first_bootstrap_refreshes_only_runtime_without_durable_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
result = runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert result["bootstrapped"] is True
|
||||
assert result["runtime_rehydrated"] is False
|
||||
assert result["durable_branch_lease_present"] is False
|
||||
assert result["idempotent"] is False
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_duplicate_bootstrap_is_idempotent_without_consuming_generation(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
first = runtime.bootstrap_writer_lease(writer_request())
|
||||
second = runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert first["bootstrapped"] is True
|
||||
assert first["generation"] == 1
|
||||
assert second["bootstrapped"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["generation"] == 1
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_commit_after_bootstrap_reuses_the_exact_runtime_binding(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
class RecordingWorkspace:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, bytes] = {}
|
||||
|
||||
def materialize_files(self, files: dict[str, bytes]) -> None:
|
||||
self.files = dict(files)
|
||||
|
||||
class RecordingExecutor:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspace = RecordingWorkspace()
|
||||
self.binding = None
|
||||
self.commit = None
|
||||
|
||||
def execute(self, binding: object, commit: object) -> dict[str, object]:
|
||||
self.binding = binding
|
||||
self.commit = commit
|
||||
return {'task_id': binding.request.task_id, 'generation': binding.request.generation, 'files': sorted(self.workspace.files)}
|
||||
executor = RecordingExecutor()
|
||||
original_build = runtime._build_runtime
|
||||
|
||||
def build(lease: WriterLeaseRequestV2, *, job_id: str, lease_not_before: datetime | None=None, lease_expires_at: datetime | None=None) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||
binding, controller, _ = original_build(lease, job_id=job_id, lease_not_before=lease_not_before, lease_expires_at=lease_expires_at)
|
||||
return (binding, controller, executor)
|
||||
runtime._build_runtime = build
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
lease_response = runtime.acquire_lease(writer_request())
|
||||
binding, controller, executor = runtime.runtimes[TASK_ID, 1]
|
||||
assert lease_response['job_id'] == binding.job_id
|
||||
assert lease_response['durable_branch_lease_present'] is True
|
||||
_original_execute = executor.execute
|
||||
|
||||
def _execute_with_governed_commit_contract(*args, **kwargs):
|
||||
_before_complete = kwargs.pop('before_complete', None)
|
||||
_legacy = dict(_original_execute(*args, **kwargs))
|
||||
_result = {**_legacy, 'job_id': binding.job_id, 'state': 'COMPLETED', 'side_effect_state': 'REMOTE_VERIFIED', 'receipt': {'authority_oid': 'b' * 40, 'git_remote_oid': 'b' * 40, 'api_branch_oid': 'b' * 40, 'commit_visible': True, 'remote_verified': True, 'force_push': False}}
|
||||
if _before_complete is not None:
|
||||
_before_complete('b' * 40)
|
||||
return _result
|
||||
executor.execute = _execute_with_governed_commit_contract
|
||||
_bound_job_id = binding.job_id
|
||||
result = runtime.commit_files({'task_id': TASK_ID, 'repository': 'aios/aios', 'generation': 1, 'target_branch': BRANCH, 'expected_branch_oid': BASE, 'idempotency_key': 'commit-after-bootstrap-v1', 'path': 'docs/recovery.txt', 'content_base64': 'YQ==', 'message': 'test commit after bootstrap'})
|
||||
assert result['task_id'] == TASK_ID
|
||||
assert result['generation'] == 1
|
||||
assert result['files'] == ['docs/recovery.txt']
|
||||
assert executor.binding.request.task_id == TASK_ID
|
||||
assert executor.binding.request.generation == 1
|
||||
database = runtime._job_root(TASK_ID) / 'state.sqlite3'
|
||||
with sqlite3.connect(f'file:{database}?mode=ro', uri=True) as connection:
|
||||
assert connection.execute('SELECT COUNT(*) FROM branch_leases').fetchone()[0] == 1
|
||||
assert connection.execute('PRAGMA foreign_key_check(branch_leases)').fetchall() == []
|
||||
assert result['commit_oid'] == 'b' * 40
|
||||
assert result['commit_visible'] is True
|
||||
assert result['branch_mode'] == 'CONTINUE_EXISTING'
|
||||
assert result['target_branch'] == BRANCH
|
||||
assert int(result['generation']) == 1
|
||||
with controller.store._connect() as connection:
|
||||
_persisted = connection.execute('SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||
assert _persisted is not None
|
||||
assert _persisted[0] == 'b' * 40
|
||||
assert int(_persisted[1]) == 1
|
||||
fresh_runtime = make_runtime(tmp_path, authority_head='b' * 40)
|
||||
fresh_response = fresh_runtime.bootstrap_writer_lease(writer_request(branch_mode='CONTINUE_EXISTING', expected_branch_oid='b' * 40))
|
||||
fresh_binding, _, _ = fresh_runtime.runtimes[TASK_ID, 1]
|
||||
assert fresh_response['job_id'] == str(_bound_job_id)
|
||||
assert fresh_response['runtime_rehydrated'] is True
|
||||
assert fresh_binding.job_id == str(_bound_job_id)
|
||||
assert fresh_binding.request.expected_branch_oid == 'b' * 40
|
||||
with fresh_runtime.controllers[TASK_ID].store._connect() as connection:
|
||||
_fresh_lease = connection.execute('SELECT job_id,current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||
assert tuple(_fresh_lease) == (str(_bound_job_id), 'b' * 40, 1)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "error"),
|
||||
(
|
||||
("repository", "other/repository", "LEASE_BINDING_CONFLICT"),
|
||||
("base_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||
("expected_branch_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||
("target_branch", "agent/wrong-target", "LEASE_BINDING_CONFLICT"),
|
||||
("generation", 2, "GENERATION_BINDING_CONFLICT"),
|
||||
),
|
||||
)
|
||||
def test_bootstrap_rejects_any_nonmatching_binding_field(
|
||||
tmp_path: Path, field: str, value: object, error: str
|
||||
) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match=error):
|
||||
runtime.bootstrap_writer_lease(writer_request(**{field: value}))
|
||||
|
||||
|
||||
def test_bootstrap_rejects_unknown_task_ownership(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="TASK_OWNERSHIP_INVALID"):
|
||||
runtime.bootstrap_writer_lease(writer_request(task_id="unknown-task"))
|
||||
|
||||
|
||||
def test_renew_missing_durable_lease_is_not_bootstrap(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_NOT_FOUND"):
|
||||
runtime.renew_writer_lease(writer_request())
|
||||
|
||||
|
||||
def test_renew_is_generation_scoped_before_durable_lookup(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="GENERATION_BINDING_CONFLICT"):
|
||||
runtime.renew_writer_lease(writer_request(generation=2))
|
||||
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 0)
|
||||
|
||||
|
||||
def test_restart_rehydrates_only_from_matching_durable_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
runtime.runtimes.clear()
|
||||
|
||||
result = runtime.renew_writer_lease(writer_request())
|
||||
|
||||
expires_at, revision = durable_row(runtime)
|
||||
assert result["recovered"] is True
|
||||
assert result["runtime_rehydrated"] is True
|
||||
assert result["durable_branch_lease_present"] is True
|
||||
assert revision == 1
|
||||
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_duplicate_renewal_is_idempotent_after_first_durable_recovery(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
|
||||
first = runtime.renew_writer_lease(writer_request())
|
||||
second = runtime.renew_writer_lease(writer_request())
|
||||
|
||||
assert first["recovered"] is True
|
||||
assert first["idempotent"] is False
|
||||
assert second["recovered"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert durable_row(runtime)[1] == 1
|
||||
|
||||
|
||||
def test_update_lease_keeps_expiry_without_optional_argument_and_keeps_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
controller = create_durable_lease(runtime)
|
||||
|
||||
controller.update_lease(
|
||||
"writer-lease-recovery",
|
||||
expected_revision=0,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
)
|
||||
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 1)
|
||||
with pytest.raises(JobError, match="LEASE_REVISION_CAS_CONFLICT"):
|
||||
controller.update_lease(
|
||||
"writer-lease-recovery",
|
||||
expected_revision=0,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
)
|
||||
|
||||
|
||||
def test_two_runtime_connections_resolve_renewal_cas_race_idempotently(tmp_path: Path) -> None:
|
||||
first = make_runtime(tmp_path)
|
||||
first_controller = create_durable_lease(first)
|
||||
second = make_runtime(tmp_path)
|
||||
second_controller = GitOpsJobController(
|
||||
JobStore(second._job_root(TASK_ID), payload_root=second._job_root(TASK_ID) / "payloads")
|
||||
)
|
||||
second.controllers[TASK_ID] = second_controller
|
||||
gate = threading.Barrier(2)
|
||||
|
||||
def gate_update(controller: GitOpsJobController) -> None:
|
||||
original = controller.update_lease
|
||||
|
||||
def update(*args: object, **kwargs: object) -> None:
|
||||
gate.wait(timeout=5)
|
||||
original(*args, **kwargs)
|
||||
|
||||
controller.update_lease = update
|
||||
|
||||
gate_update(first_controller)
|
||||
gate_update(second_controller)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda runtime: runtime.renew_writer_lease(writer_request()), (first, second)))
|
||||
|
||||
assert sorted((item["recovered"], item["idempotent"]) for item in results) == [
|
||||
(False, True),
|
||||
(True, False),
|
||||
]
|
||||
assert durable_row(first)[1] == 1
|
||||
|
||||
|
||||
def test_bootstrap_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
response = runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_restart_rehydrate_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||
first = make_runtime(tmp_path)
|
||||
expected_job_id = first.bindings[TASK_ID]["job_id"]
|
||||
first.acquire_lease(writer_request())
|
||||
|
||||
runtime = make_runtime(tmp_path)
|
||||
assert runtime is not first
|
||||
assert runtime.runtimes == {}
|
||||
response = runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_wrong_governed_job_id_is_denied_before_runtime_or_lease_side_effect(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime.bootstrap_writer_lease(writer_request(job_id="job-wrong-lineage"))
|
||||
|
||||
assert runtime.runtimes == {}
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||
|
||||
|
||||
def test_acquire_creates_exact_governed_writer_lease_with_fk_and_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
response = runtime.acquire_lease(writer_request())
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert response["durable_branch_lease_present"] is True
|
||||
assert response["idempotent"] is False
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
foreign_keys = connection.execute(
|
||||
"PRAGMA foreign_key_list(branch_leases)"
|
||||
).fetchall()
|
||||
row = connection.execute(
|
||||
"SELECT job_id,branch,lineage_anchor_oid,current_head_oid,generation,state,revision "
|
||||
"FROM branch_leases"
|
||||
).fetchone()
|
||||
job = connection.execute(
|
||||
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||
assert any(
|
||||
fk[2] == "jobs" and fk[3] == "job_id" and fk[4] == "job_id"
|
||||
for fk in foreign_keys
|
||||
)
|
||||
assert row == (expected_job_id, BRANCH, BASE, BASE, 1, "ACTIVE", 0)
|
||||
assert job == ("LEASED", "LEASED", 2)
|
||||
|
||||
|
||||
def test_duplicate_acquire_observes_same_durable_writer_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
first = runtime.acquire_lease(writer_request())
|
||||
second = runtime.acquire_lease(writer_request())
|
||||
|
||||
assert first["job_id"] == expected_job_id
|
||||
assert second["job_id"] == expected_job_id
|
||||
assert second["idempotent"] is True
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
leases = connection.execute(
|
||||
"SELECT lease_id,job_id,revision FROM branch_leases"
|
||||
).fetchall()
|
||||
job = connection.execute(
|
||||
"SELECT state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
assert leases == [("lease-" + expected_job_id, expected_job_id, 0)]
|
||||
assert job == ("LEASED", 2)
|
||||
|
||||
|
||||
def test_wrong_job_acquire_is_denied_before_authorize_or_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime.acquire_lease(writer_request(job_id="job-wrong-lineage"))
|
||||
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
job = connection.execute(
|
||||
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
lease_count = connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0]
|
||||
assert job == ("REQUESTED", "NONE", 0)
|
||||
assert lease_count == 0
|
||||
|
||||
|
||||
def test_omitted_request_job_id_resolves_the_exact_durable_parent(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
raw = dict(writer_request())
|
||||
|
||||
response = runtime.acquire_lease(raw)
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_durable_job_expected_head_mismatch_is_denied(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
controller = runtime.controllers[TASK_ID]
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT request_json FROM jobs WHERE job_id=?", (expected_job_id,)
|
||||
).fetchone()
|
||||
request = json.loads(row[0])
|
||||
request["expected_branch_oid"] = "c" * 40
|
||||
request["binding"]["expected_branch_oid"] = "c" * 40
|
||||
connection.execute(
|
||||
"UPDATE jobs SET request_json=? WHERE job_id=?",
|
||||
(json.dumps(request, sort_keys=True, separators=(",", ":")), expected_job_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
with pytest.raises(RuntimeError, match="RUNTIME_GOVERNED_JOB_UNRESOLVED"):
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert runtime.runtimes == {}
|
||||
|
||||
|
||||
def _commit_ready_runtime(tmp_path: Path):
|
||||
runtime = make_runtime(tmp_path)
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, controller, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
class Workspace:
|
||||
def materialize_files(self, _files):
|
||||
return None
|
||||
|
||||
class Executor:
|
||||
def __init__(self):
|
||||
self.workspace = Workspace()
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, _binding, _commit, *, before_complete=None):
|
||||
self.calls += 1
|
||||
result = {
|
||||
"job_id": binding.job_id,
|
||||
"state": "COMPLETED",
|
||||
"side_effect_state": "REMOTE_VERIFIED",
|
||||
"receipt": {
|
||||
"authority_oid": "b" * 40,
|
||||
"git_remote_oid": "b" * 40,
|
||||
"api_branch_oid": "b" * 40,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"force_push": False,
|
||||
},
|
||||
}
|
||||
if before_complete is not None:
|
||||
before_complete("b" * 40)
|
||||
return result
|
||||
|
||||
executor = Executor()
|
||||
runtime.runtimes[(TASK_ID, 1)] = (binding, controller, executor)
|
||||
runtime.acquire_lease(writer_request())
|
||||
return runtime, binding, controller, executor
|
||||
|
||||
|
||||
def _commit_request():
|
||||
return {
|
||||
"task_id": TASK_ID,
|
||||
"repository": "aios/aios",
|
||||
"generation": 1,
|
||||
"target_branch": BRANCH,
|
||||
"expected_branch_oid": BASE,
|
||||
"idempotency_key": "commit-cas-test-v1",
|
||||
"path": "docs/cas.txt",
|
||||
"content_base64": "YQ==",
|
||||
"message": "test commit cas",
|
||||
}
|
||||
|
||||
|
||||
def test_commit_lease_revision_cas_conflict_is_preserved(tmp_path: Path) -> None:
|
||||
runtime, _binding, controller, _executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def conflict(*_args, **_kwargs):
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
|
||||
controller.update_lease = conflict
|
||||
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||
runtime.commit_files(_commit_request())
|
||||
|
||||
|
||||
def test_commit_divergent_durable_head_fails_before_executor(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
controller.update_lease(
|
||||
"lease-" + binding.job_id,
|
||||
expected_revision=0,
|
||||
current_head_oid="c" * 40,
|
||||
generation=1,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_HEAD_RECONCILIATION_REQUIRED"):
|
||||
runtime.commit_files(_commit_request())
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_remote_pending_never_advances_durable_head(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def pending(_binding, _commit, **_kwargs):
|
||||
executor.calls += 1
|
||||
return {
|
||||
"job_id": binding.job_id,
|
||||
"state": "RECONCILIATION_REQUIRED",
|
||||
"side_effect_state": "REMOTE_PUSH_PENDING",
|
||||
"receipt": {
|
||||
"authority_oid": "b" * 40,
|
||||
"desired_remote_oid": "b" * 40,
|
||||
"remote_verified": False,
|
||||
},
|
||||
}
|
||||
|
||||
executor.execute = pending
|
||||
with pytest.raises(RuntimeError, match="RECONCILIATION_REQUIRED"):
|
||||
runtime.commit_files(_commit_request())
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (BASE, 0)
|
||||
|
||||
|
||||
def test_retry_accepts_exact_head_already_advanced_at_remote_verified_boundary(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
commit_oid = "b" * 40
|
||||
controller.update_lease(
|
||||
"lease-" + binding.job_id,
|
||||
expected_revision=0,
|
||||
current_head_oid=commit_oid,
|
||||
generation=1,
|
||||
)
|
||||
receipt = {
|
||||
"authority_oid": commit_oid,
|
||||
"git_remote_oid": commit_oid,
|
||||
"api_branch_oid": commit_oid,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"force_push": False,
|
||||
}
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET state='REMOTE_VERIFIED',side_effect_state='REMOTE_VERIFIED',"
|
||||
"receipt_json=? WHERE job_id=?",
|
||||
(json.dumps(receipt, sort_keys=True, separators=(",", ":")), binding.job_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
result = runtime.commit_files(_commit_request())
|
||||
|
||||
assert result["commit_oid"] == commit_oid
|
||||
assert executor.calls == 1
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (commit_oid, 1)
|
||||
|
||||
|
||||
def test_completed_response_with_invalid_oid_uses_canonical_oid_error(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def invalid_oid(_binding, _commit, **_kwargs):
|
||||
executor.calls += 1
|
||||
return {
|
||||
"job_id": binding.job_id,
|
||||
"state": "COMPLETED",
|
||||
"side_effect_state": "REMOTE_VERIFIED",
|
||||
"receipt": {
|
||||
"authority_oid": "not-an-oid",
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
},
|
||||
}
|
||||
|
||||
executor.execute = invalid_oid
|
||||
with pytest.raises(RuntimeError, match="GOVERNED_COMMIT_OID_INVALID"):
|
||||
runtime.commit_files(_commit_request())
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (BASE, 0)
|
||||
|
||||
|
||||
def test_acquire_renews_an_expired_matching_durable_lease_with_revision_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
runtime.runtimes.clear()
|
||||
|
||||
result = runtime.acquire_lease(writer_request())
|
||||
|
||||
expires_at, revision = durable_row(runtime)
|
||||
assert result["recovered"] is True
|
||||
assert result["runtime_rehydrated"] is False
|
||||
assert result["durable_branch_lease_present"] is True
|
||||
assert revision == 1
|
||||
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_authority_recovers_only_clean_detached_single_attempt_worktree(tmp_path: Path) -> None:
|
||||
import subprocess
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
|
||||
bare = tmp_path / "authority.git"
|
||||
seed = tmp_path / "seed"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(seed)], check=True, capture_output=True)
|
||||
(seed / "base.txt").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(seed), "add", "base.txt"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(seed), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||
"commit", "-m", "base"], check=True, capture_output=True,
|
||||
)
|
||||
base = subprocess.check_output(["git", "-C", str(seed), "rev-parse", "HEAD"], text=True).strip()
|
||||
subprocess.run(["git", "-C", str(seed), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||
root = tmp_path / "worktrees"
|
||||
authority = AuthorityGitExecutor(bare, root)
|
||||
worktree = authority.create_detached_worktree(base, "task")
|
||||
(worktree / "attempt.txt").write_text("attempt\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(worktree), "add", "attempt.txt"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(worktree), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||
"commit", "-m", "failed attempt"], check=True, capture_output=True,
|
||||
)
|
||||
stale = subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip()
|
||||
|
||||
recovered = authority.recover_failed_detached_worktree_exact(
|
||||
path=worktree,
|
||||
expected_oid=base,
|
||||
stale_oid=stale,
|
||||
target_ref="refs/heads/agent/recovery-test",
|
||||
)
|
||||
|
||||
assert recovered == stale
|
||||
assert subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip() == base
|
||||
assert subprocess.run(["git", "-C", str(worktree), "symbolic-ref", "-q", "HEAD"]).returncode == 1
|
||||
assert subprocess.check_output(["git", "-C", str(worktree), "status", "--porcelain"], text=True) == ""
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = path.with_name(path.name + f".bak.{stamp}")
|
||||
shutil.copy2(path, out)
|
||||
return out
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Repair misplaced subprocess import in execution-rebind runtime."
|
||||
)
|
||||
ap.add_argument("--rebind", required=True)
|
||||
args = ap.parse_args()
|
||||
path = pathlib.Path(args.rebind).resolve()
|
||||
if not path.is_file():
|
||||
die(f"missing rebind source: {path}")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marker = "from __future__ import annotations\n"
|
||||
if marker not in text:
|
||||
die("future import marker missing")
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
filtered = [line for line in lines if line.strip() != "import subprocess"]
|
||||
text2 = "".join(filtered)
|
||||
|
||||
future_index = text2.find(marker)
|
||||
if future_index < 0:
|
||||
die("future import marker missing after normalization")
|
||||
|
||||
insert_at = future_index + len(marker)
|
||||
normalized = text2[:insert_at] + "\nimport subprocess\n" + text2[insert_at:]
|
||||
|
||||
if normalized == text:
|
||||
print("OK import order already normalized")
|
||||
else:
|
||||
b = backup(path)
|
||||
path.write_text(normalized, encoding="utf-8")
|
||||
print(f"PATCHED import order; backup={b}")
|
||||
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
print(f"OK syntax: {path}")
|
||||
|
||||
current = path.read_text(encoding="utf-8")
|
||||
required_markers = (
|
||||
"EXECUTION_REBIND_NON_FAST_FORWARD_DENY",
|
||||
"old_expected_branch_oid",
|
||||
"new_expected_branch_oid",
|
||||
)
|
||||
missing = [m for m in required_markers if m not in current]
|
||||
if missing:
|
||||
die("re-anchor patch markers missing: " + ",".join(missing))
|
||||
|
||||
print("RESULT: IMPORT_ORDER_REPAIRED")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = path.with_name(path.name + f".bak.{stamp}")
|
||||
shutil.copy2(path, out)
|
||||
return out
|
||||
|
||||
def ensure_import(path: pathlib.Path, import_line: str) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if import_line + "\n" in text:
|
||||
return
|
||||
lines = text.splitlines(keepends=True)
|
||||
at = 0
|
||||
if lines and lines[0].startswith("#!"):
|
||||
at = 1
|
||||
if at < len(lines) and lines[at].startswith("from __future__ import"):
|
||||
at += 1
|
||||
lines.insert(at, import_line + "\n")
|
||||
path.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> None:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if new in text:
|
||||
print(f"OK already patched: {label}")
|
||||
return
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
die(f"{label}: expected exactly one match, found {count}")
|
||||
b = backup(path)
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||
print(f"PATCHED {label}; backup={b}")
|
||||
|
||||
def syntax_check(path: pathlib.Path) -> None:
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
print(f"OK syntax: {path}")
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Add guarded same-job execution re-anchor support.")
|
||||
ap.add_argument("--rebind", required=True)
|
||||
args = ap.parse_args()
|
||||
path = pathlib.Path(args.rebind).resolve()
|
||||
if not path.is_file():
|
||||
die(f"missing rebind source: {path}")
|
||||
|
||||
ensure_import(path, "import subprocess")
|
||||
|
||||
old = ''' binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||
resolved = dict(self.resolver(task_id, capabilities))
|
||||
new_execution = validate_execution_envelope(resolved, binding=binding)
|
||||
if new_execution.get("action") != "deterministic":
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||
'''
|
||||
new = ''' binding = GovernedExecutionBinding.from_mapping(old_execution)
|
||||
resolved = dict(self.resolver(task_id, capabilities))
|
||||
candidate_binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
|
||||
# Same-job continuation may advance only the branch head (and may
|
||||
# transition CREATE_NEW -> CONTINUE_EXISTING after the branch exists).
|
||||
immutable_fields = (
|
||||
"task_id",
|
||||
"job_id",
|
||||
"repository",
|
||||
"base_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"idempotency_key",
|
||||
)
|
||||
for field in immutable_fields:
|
||||
if getattr(candidate_binding, field) != getattr(binding, field):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_BINDING_CONFLICT", 409)
|
||||
|
||||
head_advanced = candidate_binding.expected_branch_oid != binding.expected_branch_oid
|
||||
if head_advanced:
|
||||
allowed_modes = {
|
||||
(binding.branch_mode, binding.branch_mode),
|
||||
("CREATE_NEW", "CONTINUE_EXISTING"),
|
||||
}
|
||||
if (binding.branch_mode, candidate_binding.branch_mode) not in allowed_modes:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_BRANCH_MODE_CONFLICT", 409)
|
||||
|
||||
authority_git_dir = "/data/aios-gitops/repo.git"
|
||||
authority_ref = f"refs/heads/{binding.target_branch}"
|
||||
try:
|
||||
authority_head = subprocess.check_output(
|
||||
[
|
||||
"/usr/bin/git",
|
||||
f"--git-dir={authority_git_dir}",
|
||||
"rev-parse",
|
||||
authority_ref,
|
||||
],
|
||||
text=True,
|
||||
timeout=5,
|
||||
).strip()
|
||||
ancestry = subprocess.run(
|
||||
[
|
||||
"/usr/bin/git",
|
||||
f"--git-dir={authority_git_dir}",
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
binding.expected_branch_oid,
|
||||
candidate_binding.expected_branch_oid,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_AUTHORITY_GIT_UNAVAILABLE", 503) from exc
|
||||
|
||||
if authority_head != candidate_binding.expected_branch_oid:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_CURRENT_HEAD_MISMATCH", 409)
|
||||
if ancestry.returncode != 0:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_NON_FAST_FORWARD_DENY", 409)
|
||||
elif candidate_binding.branch_mode != binding.branch_mode:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_BRANCH_MODE_CONFLICT", 409)
|
||||
|
||||
# Validate the canonical envelope on its own after the continuation
|
||||
# binding proof above. Worker-side strict equality remains unchanged.
|
||||
new_execution = validate_execution_envelope(resolved)
|
||||
|
||||
# Re-anchor may legitimately remain source_capture when no deterministic
|
||||
# plan is registered yet. A non-head-changing rebind must still advance
|
||||
# source_capture -> deterministic.
|
||||
if new_execution.get("action") not in {"source_capture", "deterministic"}:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTION_INVALID", 409)
|
||||
if not head_advanced and new_execution.get("action") != "deterministic":
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DETERMINISTIC_PLAN_REQUIRED", 409)
|
||||
if new_execution["execution_digest"] != request.expected_new_execution_digest:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_NEW_DIGEST_DRIFT", 409)
|
||||
if new_execution["execution_digest"] == old_execution["execution_digest"]:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_DIGEST_UNCHANGED", 409)
|
||||
'''
|
||||
replace_once(path, old, new, "same-job fast-forward execution re-anchor")
|
||||
|
||||
old_event = ''' "old_action": old_execution.get("action"),
|
||||
"new_action": new_execution.get("action"),
|
||||
"previous_revision": request.expected_revision,
|
||||
'''
|
||||
new_event = ''' "old_action": old_execution.get("action"),
|
||||
"new_action": new_execution.get("action"),
|
||||
"old_expected_branch_oid": binding.expected_branch_oid,
|
||||
"new_expected_branch_oid": candidate_binding.expected_branch_oid,
|
||||
"head_advanced": head_advanced,
|
||||
"previous_revision": request.expected_revision,
|
||||
'''
|
||||
replace_once(path, old_event, new_event, "re-anchor audit evidence")
|
||||
syntax_check(path)
|
||||
print("RESULT: PATCH_OK")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = path.with_name(path.name + f".bak.{stamp}")
|
||||
shutil.copy2(path, out)
|
||||
return out
|
||||
|
||||
def ensure_datetime_import(path: pathlib.Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
target = "from datetime import datetime, timezone\n"
|
||||
if target in text:
|
||||
return False
|
||||
lines = text.splitlines(keepends=True)
|
||||
at = 0
|
||||
if lines and lines[0].startswith("#!"):
|
||||
at = 1
|
||||
if at < len(lines) and lines[at].startswith("from __future__ import"):
|
||||
at += 1
|
||||
b = backup(path)
|
||||
lines.insert(at, target)
|
||||
path.write_text("".join(lines), encoding="utf-8")
|
||||
print(f"PATCHED datetime import: {path}; backup={b}")
|
||||
return True
|
||||
|
||||
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if new in text:
|
||||
print(f"OK already patched: {label}: {path}")
|
||||
return False
|
||||
n = text.count(old)
|
||||
if n != 1:
|
||||
die(f"{label}: expected exactly 1 match, found {n}: {path}")
|
||||
b = backup(path)
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||
print(f"PATCHED {label}: {path}; backup={b}")
|
||||
return True
|
||||
|
||||
def syntax_check(path: pathlib.Path) -> None:
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
print(f"OK syntax: {path}")
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--rebind", required=True)
|
||||
ap.add_argument("--worker", required=True)
|
||||
ap.add_argument("--bridge", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
rebind = pathlib.Path(args.rebind).resolve()
|
||||
worker = pathlib.Path(args.worker).resolve()
|
||||
bridge = pathlib.Path(args.bridge).resolve()
|
||||
for p in (rebind, worker, bridge):
|
||||
if not p.is_file():
|
||||
die(f"missing file: {p}")
|
||||
|
||||
old_rebind = ''' if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||
'''
|
||||
new_rebind = ''' # A persisted lease token is not by itself proof of an active lease.
|
||||
lease_expires_at = task["lease_expires_at"]
|
||||
if lease_expires_at not in (None, ""):
|
||||
try:
|
||||
parsed_lease_expires_at = datetime.fromisoformat(
|
||||
str(lease_expires_at).replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_LEASE_EXPIRY_INVALID", 409) from exc
|
||||
if parsed_lease_expires_at > datetime.now(timezone.utc):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||
'''
|
||||
|
||||
old_worker = ''' active = [
|
||||
item for item in leases
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("state") == "ACTIVE"
|
||||
and item.get("job_id") == envelope.job_id
|
||||
and item.get("branch") == envelope.target_branch
|
||||
]
|
||||
'''
|
||||
new_worker = ''' now = datetime.now(timezone.utc)
|
||||
active = []
|
||||
for item in leases:
|
||||
if (
|
||||
not isinstance(item, Mapping)
|
||||
or item.get("state") != "ACTIVE"
|
||||
or item.get("job_id") != envelope.job_id
|
||||
or item.get("branch") != envelope.target_branch
|
||||
):
|
||||
continue
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
str(item.get("lease_expires_at") or "").replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if expires_at > now:
|
||||
active.append(item)
|
||||
'''
|
||||
|
||||
old_bridge = ''' if durable_expires_at <= datetime.now(timezone.utc):
|
||||
return self.renew_writer_lease(request)
|
||||
'''
|
||||
new_bridge = ''' # Expired durable lease is recoverable on the same task/job/generation.
|
||||
if durable_expires_at <= datetime.now(timezone.utc):
|
||||
return self.renew_writer_lease(request)
|
||||
'''
|
||||
|
||||
changed = []
|
||||
for p in (rebind, worker):
|
||||
if ensure_datetime_import(p):
|
||||
changed.append(p)
|
||||
if replace_once(rebind, old_rebind, new_rebind, "execution-rebind effective lease expiry"):
|
||||
changed.append(rebind)
|
||||
if replace_once(worker, old_worker, new_worker, "worker effective lease expiry"):
|
||||
changed.append(worker)
|
||||
if replace_once(bridge, old_bridge, new_bridge, "bridge actual-runtime marker"):
|
||||
changed.append(bridge)
|
||||
|
||||
for p in (rebind, worker, bridge):
|
||||
syntax_check(p)
|
||||
print("RESULT: PATCH_OK")
|
||||
print("CHANGED:", ",".join(dict.fromkeys(str(p) for p in changed)) if changed else "none")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = path.with_name(path.name + f".bak.{stamp}")
|
||||
shutil.copy2(path, out)
|
||||
return out
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Allow pre-claim execution rebind at attempt=0.")
|
||||
ap.add_argument("--rebind", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
path = pathlib.Path(args.rebind).resolve()
|
||||
if not path.is_file():
|
||||
die(f"missing rebind source: {path}")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
old = " or attempt < 1\n"
|
||||
new = " or attempt < 0\n"
|
||||
|
||||
if new in text:
|
||||
print("OK already patched: pre-claim attempt=0")
|
||||
else:
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
die(f"expected exactly one attempt<1 guard, found {count}")
|
||||
b = backup(path)
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||
print(f"PATCHED pre-claim attempt=0; backup={b}")
|
||||
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
|
||||
current = path.read_text(encoding="utf-8")
|
||||
for marker in (
|
||||
"or attempt < 0",
|
||||
"EXECUTION_REBIND_NON_FAST_FORWARD_DENY",
|
||||
"old_expected_branch_oid",
|
||||
"new_expected_branch_oid",
|
||||
):
|
||||
if marker not in current:
|
||||
die(f"required marker missing: {marker}")
|
||||
|
||||
print(f"OK syntax: {path}")
|
||||
print("RESULT: PRECLAIM_REBIND_PATCH_OK")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import datetime as dt
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
UTC = dt.timezone.utc
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
|
||||
def backup(path: pathlib.Path) -> pathlib.Path:
|
||||
stamp = dt.datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
out = path.with_suffix(path.suffix + f".bak.{stamp}")
|
||||
shutil.copy2(path, out)
|
||||
return out
|
||||
|
||||
def replace_once(path: pathlib.Path, old: str, new: str, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if new in text:
|
||||
print(f"OK already patched: {label}")
|
||||
return False
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
die(f"{label}: expected exactly 1 match, found {count} in {path}")
|
||||
backup_path = backup(path)
|
||||
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
||||
print(f"PATCHED {label}; backup={backup_path}")
|
||||
return True
|
||||
|
||||
def ensure_datetime_import(path: pathlib.Path) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
target = "from datetime import datetime, timezone\n"
|
||||
if target in text:
|
||||
return False
|
||||
lines = text.splitlines(keepends=True)
|
||||
insert_at = 0
|
||||
if lines and lines[0].startswith("#!"):
|
||||
insert_at = 1
|
||||
if insert_at < len(lines) and lines[insert_at].startswith("from __future__ import"):
|
||||
insert_at += 1
|
||||
lines.insert(insert_at, target)
|
||||
backup_path = backup(path)
|
||||
path.write_text("".join(lines), encoding="utf-8")
|
||||
print(f"PATCHED datetime import: {path}; backup={backup_path}")
|
||||
return True
|
||||
|
||||
def syntax_check(path: pathlib.Path) -> None:
|
||||
ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
print(f"OK syntax: {path}")
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Repair stale governed writer lease handling.")
|
||||
ap.add_argument("--app-dir", default="/opt/aios/current/active-runtime/app")
|
||||
args = ap.parse_args()
|
||||
app = pathlib.Path(args.app_dir)
|
||||
|
||||
rebind = app / "aios_agent_task_execution_rebind.py"
|
||||
worker = app / "aios_online_agent_worker.py"
|
||||
bridge = app / "aios_chatgpt_bridge_v2_server.py"
|
||||
for p in (rebind, worker, bridge):
|
||||
if not p.is_file():
|
||||
die(f"missing file: {p}")
|
||||
|
||||
old_rebind = ''' if task["lease_token_hash"] not in (None, "") or task["lease_expires_at"] not in (None, ""):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||
'''
|
||||
new_rebind = ''' # A persisted lease token is not by itself proof of an active lease.
|
||||
# Treat an unexpired lease as active; stale lease projection is recoverable.
|
||||
lease_expires_at = task["lease_expires_at"]
|
||||
if lease_expires_at not in (None, ""):
|
||||
try:
|
||||
parsed_lease_expires_at = datetime.fromisoformat(
|
||||
str(lease_expires_at).replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_LEASE_EXPIRY_INVALID", 409) from exc
|
||||
if parsed_lease_expires_at > datetime.now(timezone.utc):
|
||||
raise ExecutionRebindError("EXECUTION_REBIND_ACTIVE_LEASE_DENY", 409)
|
||||
'''
|
||||
|
||||
old_worker = ''' active = [
|
||||
item for item in leases
|
||||
if isinstance(item, Mapping)
|
||||
and item.get("state") == "ACTIVE"
|
||||
and item.get("job_id") == envelope.job_id
|
||||
and item.get("branch") == envelope.target_branch
|
||||
]
|
||||
'''
|
||||
new_worker = ''' now = datetime.now(timezone.utc)
|
||||
active = []
|
||||
for item in leases:
|
||||
if (
|
||||
not isinstance(item, Mapping)
|
||||
or item.get("state") != "ACTIVE"
|
||||
or item.get("job_id") != envelope.job_id
|
||||
or item.get("branch") != envelope.target_branch
|
||||
):
|
||||
continue
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(
|
||||
str(item.get("lease_expires_at") or "").replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if expires_at > now:
|
||||
active.append(item)
|
||||
'''
|
||||
|
||||
old_bridge = ''' if durable_expires_at <= datetime.now(timezone.utc):
|
||||
return self.renew_writer_lease(request)
|
||||
'''
|
||||
new_bridge = ''' # An expired durable lease is recoverable on the same governed
|
||||
# task/job/generation. Renew it instead of treating stale ACTIVE state
|
||||
# as a durable-binding failure.
|
||||
if durable_expires_at <= datetime.now(timezone.utc):
|
||||
return self.renew_writer_lease(request)
|
||||
'''
|
||||
|
||||
changed = []
|
||||
if ensure_datetime_import(rebind):
|
||||
changed.append(rebind)
|
||||
if ensure_datetime_import(worker):
|
||||
changed.append(worker)
|
||||
if replace_once(rebind, old_rebind, new_rebind, "execution-rebind effective lease expiry"):
|
||||
changed.append(rebind)
|
||||
if replace_once(worker, old_worker, new_worker, "worker converged-job effective lease expiry"):
|
||||
changed.append(worker)
|
||||
if replace_once(bridge, old_bridge, new_bridge, "bridge expired-lease recovery contract"):
|
||||
changed.append(bridge)
|
||||
|
||||
for p in (rebind, worker, bridge):
|
||||
syntax_check(p)
|
||||
|
||||
print("RESULT: PATCH_OK")
|
||||
print("CHANGED:", ",".join(dict.fromkeys(str(p) for p in changed)) if changed else "none")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user