Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5dc3c0f31 | ||
|
|
a3df11b693 |
@@ -13,7 +13,6 @@ from hashlib import sha256
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from message_handoff import stable_event_id
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> str:
|
def utc_now() -> str:
|
||||||
@@ -56,18 +55,13 @@ class AgentEventBus:
|
|||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
correlation_id: str | None = None,
|
correlation_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
event_id = stable_event_id(
|
event_id = "evt_" + sha256(
|
||||||
source=source, target=target, message_type=kind, payload=payload,
|
json.dumps(
|
||||||
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||||
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
sort_keys=True,
|
||||||
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
ensure_ascii=False,
|
||||||
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
).encode("utf-8")
|
||||||
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
).hexdigest()[:16]
|
||||||
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 = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||||
self._write("pending", event.to_dict())
|
self._write("pending", event.to_dict())
|
||||||
self._audit("created", event.to_dict())
|
self._audit("created", event.to_dict())
|
||||||
|
|||||||
@@ -1,427 +0,0 @@
|
|||||||
"""Owner-controlled, CAS-bound retry extension for exhausted Online Agent Tasks."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import re
|
|
||||||
import sqlite3
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
TASK_RE = re.compile(r"^[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}$")
|
|
||||||
MAX_BODY = 64 * 1024
|
|
||||||
AUTHORIZE_EXTENSION_ID_ENV = "AIOS_AGENT_TASK_AUTHORIZATION_ID"
|
|
||||||
|
|
||||||
|
|
||||||
class RetryExtensionError(RuntimeError):
|
|
||||||
def __init__(self, code: str, status: int = 409) -> None:
|
|
||||||
super().__init__(code)
|
|
||||||
self.code = code
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: object) -> bytes:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(value: object) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _required_authorization_id() -> str:
|
|
||||||
value = os.environ.get(AUTHORIZE_EXTENSION_ID_ENV, "").strip()
|
|
||||||
if not value:
|
|
||||||
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_MISSING")
|
|
||||||
if KEY_RE.fullmatch(value) is None:
|
|
||||||
raise RuntimeError("TASK_RETRY_AUTHORIZATION_ID_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _now() -> str:
|
|
||||||
return datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
|
||||||
if not isinstance(value, str) or not value.strip():
|
|
||||||
raise RetryExtensionError(code, 400)
|
|
||||||
value = value.strip()
|
|
||||||
if pattern is not None and pattern.fullmatch(value) is None:
|
|
||||||
raise RetryExtensionError(code, 400)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RetryExtensionRequest:
|
|
||||||
expected_status: str
|
|
||||||
expected_revision: int
|
|
||||||
expected_attempt: int
|
|
||||||
expected_max_attempts: int
|
|
||||||
next_max_attempts: int
|
|
||||||
previous_verification_sha256: str
|
|
||||||
authorization_id: str
|
|
||||||
idempotency_key: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_mapping(cls, value: Mapping[str, Any]) -> "RetryExtensionRequest":
|
|
||||||
allowed = {
|
|
||||||
"expected_status", "expected_revision", "expected_attempt", "expected_max_attempts",
|
|
||||||
"next_max_attempts", "previous_verification_sha256", "authorization_id", "idempotency_key",
|
|
||||||
}
|
|
||||||
if set(value) != allowed:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_FIELDS_INVALID", 400)
|
|
||||||
status = _require_string(value["expected_status"], "EXPECTED_STATUS_INVALID")
|
|
||||||
if status != "FAILED":
|
|
||||||
raise RetryExtensionError("EXPECTED_STATUS_INVALID", 400)
|
|
||||||
ints: dict[str, int] = {}
|
|
||||||
for field in ("expected_revision", "expected_attempt", "expected_max_attempts", "next_max_attempts"):
|
|
||||||
item = value[field]
|
|
||||||
if not isinstance(item, int) or isinstance(item, bool) or item < 0:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_INTEGER_INVALID", 400)
|
|
||||||
ints[field] = item
|
|
||||||
if ints["expected_attempt"] != ints["expected_max_attempts"]:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_NOT_EXHAUSTED", 409)
|
|
||||||
if ints["next_max_attempts"] != ints["expected_max_attempts"] + 1:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP", 400)
|
|
||||||
if ints["next_max_attempts"] > 50:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_LIMIT_EXCEEDED", 400)
|
|
||||||
verification = _require_string(value["previous_verification_sha256"], "VERIFICATION_SHA256_INVALID")
|
|
||||||
if re.fullmatch(r"[0-9a-f]{64}", verification) is None:
|
|
||||||
raise RetryExtensionError("VERIFICATION_SHA256_INVALID", 400)
|
|
||||||
authorization = _require_string(value["authorization_id"], "AUTHORIZATION_ID_INVALID", KEY_RE)
|
|
||||||
key = _require_string(value["idempotency_key"], "IDEMPOTENCY_KEY_INVALID", KEY_RE)
|
|
||||||
return cls(
|
|
||||||
status,
|
|
||||||
ints["expected_revision"],
|
|
||||||
ints["expected_attempt"],
|
|
||||||
ints["expected_max_attempts"],
|
|
||||||
ints["next_max_attempts"],
|
|
||||||
verification,
|
|
||||||
authorization,
|
|
||||||
key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def public(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"expected_status": self.expected_status,
|
|
||||||
"expected_revision": self.expected_revision,
|
|
||||||
"expected_attempt": self.expected_attempt,
|
|
||||||
"expected_max_attempts": self.expected_max_attempts,
|
|
||||||
"next_max_attempts": self.next_max_attempts,
|
|
||||||
"previous_verification_sha256": self.previous_verification_sha256,
|
|
||||||
"authorization_id": self.authorization_id,
|
|
||||||
"idempotency_key": self.idempotency_key,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RetryExtensionStore:
|
|
||||||
"""Owns the official mutation. Direct/manual DB edits remain forbidden."""
|
|
||||||
|
|
||||||
def __init__(self, db_path: Path) -> None:
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
|
||||||
connection.row_factory = sqlite3.Row
|
|
||||||
connection.execute("PRAGMA foreign_keys=ON")
|
|
||||||
connection.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return connection
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
|
||||||
return {str(row["name"]): row for row in connection.execute(f'PRAGMA table_info("{table}")')}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _event_table(connection: sqlite3.Connection) -> tuple[str, dict[str, sqlite3.Row]]:
|
|
||||||
tables = {
|
|
||||||
row["name"]
|
|
||||||
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
||||||
}
|
|
||||||
for name in ("task_events", "events"):
|
|
||||||
if name not in tables:
|
|
||||||
continue
|
|
||||||
columns = RetryExtensionStore._columns(connection, name)
|
|
||||||
if "task_id" in columns and ("event_type" in columns or "type" in columns):
|
|
||||||
return name, columns
|
|
||||||
raise RetryExtensionError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _verification_hash(raw: object) -> str:
|
|
||||||
if raw in (None, ""):
|
|
||||||
value: object = {}
|
|
||||||
elif isinstance(raw, str):
|
|
||||||
try:
|
|
||||||
value = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise RetryExtensionError("TASK_VERIFICATION_INVALID", 409) from exc
|
|
||||||
else:
|
|
||||||
value = raw
|
|
||||||
return _sha(value)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _ensure_extension_table(connection: sqlite3.Connection) -> None:
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS task_retry_extensions (
|
|
||||||
task_id TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
request_sha256 TEXT NOT NULL,
|
|
||||||
authorization_id TEXT NOT NULL,
|
|
||||||
previous_verification_sha256 TEXT NOT NULL,
|
|
||||||
previous_max_attempts INTEGER NOT NULL,
|
|
||||||
next_max_attempts INTEGER NOT NULL,
|
|
||||||
expected_revision INTEGER NOT NULL,
|
|
||||||
applied_revision INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY(task_id, idempotency_key)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _insert_event(
|
|
||||||
connection: sqlite3.Connection,
|
|
||||||
task_id: str,
|
|
||||||
payload: Mapping[str, Any],
|
|
||||||
) -> None:
|
|
||||||
table, columns = RetryExtensionStore._event_table(connection)
|
|
||||||
values: dict[str, Any] = {"task_id": task_id}
|
|
||||||
if "event_type" in columns:
|
|
||||||
values["event_type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
|
||||||
else:
|
|
||||||
values["type"] = "TASK_RETRY_EXTENSION_AUTHORIZED"
|
|
||||||
payload_text = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
||||||
if "payload_json" in columns:
|
|
||||||
values["payload_json"] = payload_text
|
|
||||||
elif "payload" in columns:
|
|
||||||
values["payload"] = payload_text
|
|
||||||
else:
|
|
||||||
raise RetryExtensionError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
|
||||||
if "created_at" in columns:
|
|
||||||
values["created_at"] = _now()
|
|
||||||
if "agent_id" in columns:
|
|
||||||
values["agent_id"] = "owner-control"
|
|
||||||
if "actor_id" in columns:
|
|
||||||
values["actor_id"] = "owner-control"
|
|
||||||
names = list(values)
|
|
||||||
sql = f'INSERT INTO "{table}" ({",".join(f"""\"{name}\"""" for name in names)}) VALUES ({",".join("?" for _ in names)})'
|
|
||||||
connection.execute(sql, tuple(values[name] for name in names))
|
|
||||||
|
|
||||||
def extend(self, task_id: str, request: RetryExtensionRequest) -> dict[str, Any]:
|
|
||||||
task_id = _require_string(task_id, "TASK_ID_INVALID", TASK_RE)
|
|
||||||
required_authorization_id = _required_authorization_id()
|
|
||||||
if request.authorization_id != required_authorization_id:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH", 403)
|
|
||||||
request_payload = request.public()
|
|
||||||
request_sha = _sha(request_payload)
|
|
||||||
with self._connect() as connection:
|
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
|
||||||
self._ensure_extension_table(connection)
|
|
||||||
replay = connection.execute(
|
|
||||||
"SELECT * FROM task_retry_extensions WHERE task_id=? AND idempotency_key=?",
|
|
||||||
(task_id, request.idempotency_key),
|
|
||||||
).fetchone()
|
|
||||||
if replay is not None:
|
|
||||||
if replay["request_sha256"] != request_sha:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_IDEMPOTENCY_CONFLICT", 409)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"task_id": task_id,
|
|
||||||
"status": "QUEUED",
|
|
||||||
"attempt": request.expected_attempt,
|
|
||||||
"max_attempts": int(replay["next_max_attempts"]),
|
|
||||||
"revision": int(replay["applied_revision"]),
|
|
||||||
"idempotent": True,
|
|
||||||
"authorization_id": replay["authorization_id"],
|
|
||||||
"previous_verification_sha256": replay["previous_verification_sha256"],
|
|
||||||
}
|
|
||||||
|
|
||||||
columns = self._columns(connection, "tasks")
|
|
||||||
required = {
|
|
||||||
"task_id", "status", "attempt", "max_attempts", "revision", "claimed_by",
|
|
||||||
"lease_token_hash", "lease_expires_at", "verification_json",
|
|
||||||
}
|
|
||||||
if not required.issubset(columns):
|
|
||||||
raise RetryExtensionError("TASK_SCHEMA_RETRY_EXTENSION_UNSUPPORTED", 503)
|
|
||||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
|
||||||
if task is None:
|
|
||||||
raise RetryExtensionError("TASK_NOT_FOUND", 404)
|
|
||||||
actual_verification_sha = self._verification_hash(task["verification_json"])
|
|
||||||
if actual_verification_sha != request.previous_verification_sha256:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_VERIFICATION_DRIFT", 409)
|
|
||||||
if (
|
|
||||||
task["status"] != request.expected_status
|
|
||||||
or int(task["revision"]) != request.expected_revision
|
|
||||||
or int(task["attempt"]) != request.expected_attempt
|
|
||||||
or int(task["max_attempts"]) != request.expected_max_attempts
|
|
||||||
):
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
|
||||||
for lease_field in ("lease_token_hash", "lease_expires_at"):
|
|
||||||
if lease_field in columns and task[lease_field] not in (None, ""):
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_ACTIVE_LEASE_DENY", 409)
|
|
||||||
|
|
||||||
previous_claimed_by = task["claimed_by"]
|
|
||||||
applied_revision = request.expected_revision + 1
|
|
||||||
cursor = connection.execute(
|
|
||||||
"""
|
|
||||||
UPDATE tasks
|
|
||||||
SET status='QUEUED', max_attempts=?, revision=?, claimed_by=NULL,
|
|
||||||
lease_token_hash=NULL, lease_expires_at=NULL
|
|
||||||
WHERE task_id=? AND status=? AND revision=? AND attempt=? AND max_attempts=?
|
|
||||||
AND claimed_by IS ?
|
|
||||||
AND lease_token_hash IS NULL AND lease_expires_at IS NULL
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
request.next_max_attempts,
|
|
||||||
applied_revision,
|
|
||||||
task_id,
|
|
||||||
request.expected_status,
|
|
||||||
request.expected_revision,
|
|
||||||
request.expected_attempt,
|
|
||||||
request.expected_max_attempts,
|
|
||||||
previous_claimed_by,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if cursor.rowcount != 1:
|
|
||||||
raise RetryExtensionError("RETRY_EXTENSION_CAS_MISMATCH", 409)
|
|
||||||
|
|
||||||
event_payload = {
|
|
||||||
"schema": "aios.task-retry-extension.event.v1",
|
|
||||||
"task_id": task_id,
|
|
||||||
"authorization_id": request.authorization_id,
|
|
||||||
"idempotency_key": request.idempotency_key,
|
|
||||||
"previous_verification_sha256": request.previous_verification_sha256,
|
|
||||||
"previous_max_attempts": request.expected_max_attempts,
|
|
||||||
"next_max_attempts": request.next_max_attempts,
|
|
||||||
"previous_revision": request.expected_revision,
|
|
||||||
"applied_revision": applied_revision,
|
|
||||||
"request_sha256": request_sha,
|
|
||||||
"previous_claimed_by": previous_claimed_by,
|
|
||||||
"stale_terminal_claim_normalized": previous_claimed_by not in (None, ""),
|
|
||||||
}
|
|
||||||
self._insert_event(connection, task_id, event_payload)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO task_retry_extensions(
|
|
||||||
task_id,idempotency_key,request_sha256,authorization_id,previous_verification_sha256,
|
|
||||||
previous_max_attempts,next_max_attempts,expected_revision,applied_revision,created_at
|
|
||||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
task_id,
|
|
||||||
request.idempotency_key,
|
|
||||||
request_sha,
|
|
||||||
request.authorization_id,
|
|
||||||
request.previous_verification_sha256,
|
|
||||||
request.expected_max_attempts,
|
|
||||||
request.next_max_attempts,
|
|
||||||
request.expected_revision,
|
|
||||||
applied_revision,
|
|
||||||
_now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
return {
|
|
||||||
"task_id": task_id,
|
|
||||||
"status": "QUEUED",
|
|
||||||
"attempt": request.expected_attempt,
|
|
||||||
"max_attempts": request.next_max_attempts,
|
|
||||||
"revision": applied_revision,
|
|
||||||
"idempotent": False,
|
|
||||||
"authorization_id": request.authorization_id,
|
|
||||||
"previous_verification_sha256": request.previous_verification_sha256,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RetryExtensionHandler(BaseHTTPRequestHandler):
|
|
||||||
store: RetryExtensionStore | None = None
|
|
||||||
control_token: str = ""
|
|
||||||
|
|
||||||
def log_message(self, *_args: Any) -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def _json(self, value: Mapping[str, Any], status: int) -> None:
|
|
||||||
data = json.dumps(dict(value), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(data)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(data)
|
|
||||||
|
|
||||||
def _authorized(self) -> bool:
|
|
||||||
header = self.headers.get("Authorization", "")
|
|
||||||
return (
|
|
||||||
header.startswith("Bearer ")
|
|
||||||
and bool(self.control_token)
|
|
||||||
and hmac.compare_digest(header[7:], self.control_token)
|
|
||||||
)
|
|
||||||
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
if self.path == "/health":
|
|
||||||
self._json({"status": "ok", "service": "aios-agent-task-retry-extension"}, 200)
|
|
||||||
else:
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
|
|
||||||
def do_POST(self) -> None:
|
|
||||||
match = re.fullmatch(r"/v1/tasks/([^/]+)/retry-extension", self.path)
|
|
||||||
if match is None:
|
|
||||||
self._json({"error": "NOT_FOUND"}, 404)
|
|
||||||
return
|
|
||||||
if not self._authorized():
|
|
||||||
self._json({"error": "UNAUTHORIZED"}, 401)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
length = int(self.headers.get("Content-Length", "0"))
|
|
||||||
except ValueError:
|
|
||||||
self._json({"error": "CONTENT_LENGTH_INVALID"}, 400)
|
|
||||||
return
|
|
||||||
if length < 1 or length > MAX_BODY:
|
|
||||||
self._json({"error": "REQUEST_SIZE_INVALID"}, 413)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
value = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
raise ValueError
|
|
||||||
request = RetryExtensionRequest.from_mapping(value)
|
|
||||||
if self.store is None:
|
|
||||||
raise RetryExtensionError("RUNTIME_UNAVAILABLE", 503)
|
|
||||||
result = self.store.extend(match.group(1), request)
|
|
||||||
self._json(result, 200 if result["idempotent"] else 201)
|
|
||||||
except RetryExtensionError as exc:
|
|
||||||
self._json({"error": exc.code}, exc.status)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
||||||
self._json({"error": "REQUEST_INVALID"}, 400)
|
|
||||||
|
|
||||||
|
|
||||||
def load_control_token(path: Path) -> str:
|
|
||||||
if path.is_symlink() or not path.is_file():
|
|
||||||
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_UNAVAILABLE")
|
|
||||||
value = path.read_text(encoding="utf-8").strip()
|
|
||||||
if len(value) < 32:
|
|
||||||
raise RuntimeError("TASK_RETRY_CONTROL_CREDENTIAL_INVALID")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def serve() -> None:
|
|
||||||
db = Path(os.environ.get("AIOS_AGENT_TASK_DB", "/var/lib/aios-agent-task/state.sqlite3"))
|
|
||||||
_required_authorization_id()
|
|
||||||
token_path = Path(
|
|
||||||
os.environ.get(
|
|
||||||
"AIOS_AGENT_TASK_CONTROL_TOKEN_FILE",
|
|
||||||
str(Path(os.environ.get("CREDENTIALS_DIRECTORY", "/run/credentials")) / "agent_task_control_token"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
host = os.environ.get("AIOS_TASK_RETRY_HOST", "127.0.0.1")
|
|
||||||
port = int(os.environ.get("AIOS_TASK_RETRY_PORT", "18941"))
|
|
||||||
RetryExtensionHandler.store = RetryExtensionStore(db)
|
|
||||||
RetryExtensionHandler.control_token = load_control_token(token_path)
|
|
||||||
ThreadingHTTPServer((host, port), RetryExtensionHandler).serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
serve()
|
|
||||||
@@ -94,91 +94,6 @@ class AuthorityGitExecutor:
|
|||||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||||
return path
|
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:
|
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ BRIDGE_OPERATIONS = (
|
|||||||
"getRuntimeStatus",
|
"getRuntimeStatus",
|
||||||
"getGovernedWriteCapabilities",
|
"getGovernedWriteCapabilities",
|
||||||
"createGovernedTask",
|
"createGovernedTask",
|
||||||
"projectOnlineAgentExecution",
|
|
||||||
"registerOnlineAgentExecutionPlan",
|
|
||||||
"acquireWriterLease",
|
"acquireWriterLease",
|
||||||
"bootstrapWriterLease",
|
|
||||||
"renewWriterLease",
|
|
||||||
"commitFileGoverned",
|
"commitFileGoverned",
|
||||||
"commitFilesGoverned",
|
"commitFilesGoverned",
|
||||||
"getGitOpsJob",
|
"getGitOpsJob",
|
||||||
@@ -34,14 +30,7 @@ BRIDGE_OPERATIONS = (
|
|||||||
|
|
||||||
OPERATION_REQUIRED_FIELDS = {
|
OPERATION_REQUIRED_FIELDS = {
|
||||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
"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"),
|
"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"),
|
"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"),
|
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||||
"getGitOpsJob": ("job_id",),
|
"getGitOpsJob": ("job_id",),
|
||||||
@@ -57,68 +46,6 @@ OPERATION_REQUIRED_FIELDS = {
|
|||||||
"getControlPlaneSnapshot": (),
|
"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):
|
class RuntimeDriftError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
@@ -195,14 +122,6 @@ class BridgeV2Adapter:
|
|||||||
"legacy_writes_enabled": False,
|
"legacy_writes_enabled": False,
|
||||||
"governed_writes_enabled": True,
|
"governed_writes_enabled": True,
|
||||||
"operations": list(BRIDGE_OPERATIONS),
|
"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,
|
"force_push": False,
|
||||||
"main_write": False,
|
"main_write": False,
|
||||||
"tag_write": False,
|
"tag_write": False,
|
||||||
@@ -231,11 +150,7 @@ class BridgeV2Adapter:
|
|||||||
return handler(dict(request))
|
return handler(dict(request))
|
||||||
|
|
||||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", 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 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 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 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)
|
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_exists(self, branch: str) -> bool: ...
|
||||||
def branch_head(self, branch: str) -> str: ...
|
def branch_head(self, branch: str) -> str: ...
|
||||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
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: ...
|
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||||
|
|
||||||
|
|
||||||
class RemotePort(Protocol):
|
class RemotePort(Protocol):
|
||||||
def repository_identity(self) -> str: ...
|
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 push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||||
def read_git_ref(self, branch: str) -> str | None: ...
|
def read_git_ref(self, branch: str) -> str | None: ...
|
||||||
def read_api_branch(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:
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
return self.executor.ancestry_check(older, newer)
|
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:
|
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)
|
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:
|
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||||
dirty = self._dirty_paths()
|
dirty = self._dirty_paths()
|
||||||
expected_paths = set(files)
|
expected_paths = set(files)
|
||||||
if dirty and not dirty.issubset(expected_paths):
|
if dirty and dirty != expected_paths:
|
||||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||||
for relative, content in files.items():
|
for relative, content in files.items():
|
||||||
target = (self.path / relative).resolve()
|
target = (self.path / relative).resolve()
|
||||||
@@ -263,15 +258,6 @@ class GiteaContinuationRemote:
|
|||||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||||
return oid
|
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:
|
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||||
self._require_projected()
|
self._require_projected()
|
||||||
self.repository_identity()
|
self.repository_identity()
|
||||||
@@ -343,7 +329,6 @@ class ContinuationPermissions:
|
|||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ContinuationRuntimeBinding:
|
class ContinuationRuntimeBinding:
|
||||||
request: WriterLeaseRequestV2
|
request: WriterLeaseRequestV2
|
||||||
job_id: str
|
|
||||||
workspace_path: Path
|
workspace_path: Path
|
||||||
lease_not_before: datetime
|
lease_not_before: datetime
|
||||||
lease_expires_at: datetime
|
lease_expires_at: datetime
|
||||||
@@ -404,20 +389,13 @@ class ContinuationExecutor:
|
|||||||
def observe_generation(self, branch: str, generation: int) -> None:
|
def observe_generation(self, branch: str, generation: int) -> None:
|
||||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
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)
|
observed = self._observed_generations.get(branch, 0)
|
||||||
with self.controller.store._connect() as connection:
|
with self.controller.store._connect() as connection:
|
||||||
if exclude_job_id is None:
|
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||||
(branch,),
|
(branch,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
else:
|
|
||||||
row = connection.execute(
|
|
||||||
"SELECT MAX(generation) AS generation FROM branch_leases "
|
|
||||||
"WHERE branch=? AND job_id!=?",
|
|
||||||
(branch, exclude_job_id),
|
|
||||||
).fetchone()
|
|
||||||
durable = int(row["generation"] or 0)
|
durable = int(row["generation"] or 0)
|
||||||
return max(observed, durable)
|
return max(observed, durable)
|
||||||
|
|
||||||
@@ -426,7 +404,6 @@ class ContinuationExecutor:
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
"job_id": request.job_id,
|
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
@@ -436,21 +413,10 @@ class ContinuationExecutor:
|
|||||||
"lease_seconds": request.lease_seconds,
|
"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 = binding.request
|
||||||
request_mapping = self._request_mapping(request)
|
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||||
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||||
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:
|
|
||||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||||
@@ -460,7 +426,7 @@ class ContinuationExecutor:
|
|||||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||||
if now >= binding.lease_expires_at:
|
if now >= binding.lease_expires_at:
|
||||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
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")
|
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||||
if not self.workspace.is_detached():
|
if not self.workspace.is_detached():
|
||||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||||
@@ -486,127 +452,57 @@ class ContinuationExecutor:
|
|||||||
"generation": request.generation,
|
"generation": request.generation,
|
||||||
}
|
}
|
||||||
|
|
||||||
def execute(
|
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||||
self,
|
payload = commit.payload()
|
||||||
binding: ContinuationRuntimeBinding,
|
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||||
commit: ContinuationCommit,
|
current = self.controller.store.read(submitted.job_id)
|
||||||
*,
|
if JobState(current["state"]) == JobState.COMPLETED:
|
||||||
before_complete: Callable[[str], None] | None = None,
|
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||||
) -> dict[str, object]:
|
self._validate_preflight(binding)
|
||||||
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)
|
|
||||||
request = binding.request
|
request = binding.request
|
||||||
projected = False
|
projected = False
|
||||||
try:
|
try:
|
||||||
self.credential.project()
|
self.credential.project()
|
||||||
projected = True
|
projected = True
|
||||||
row = self.controller.store.read(binding.job_id)
|
row = self.controller.store.read(submitted.job_id)
|
||||||
state = JobState(row["state"])
|
if JobState(row["state"]) == JobState.REQUESTED:
|
||||||
if state == JobState.REQUESTED:
|
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
binding.job_id,
|
submitted.job_id,
|
||||||
JobState.AUTHORIZED,
|
JobState.AUTHORIZED,
|
||||||
side_effect_state="AUTHORIZED",
|
side_effect_state="AUTHORIZED",
|
||||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||||
expected_revision=int(row["revision"]),
|
|
||||||
)
|
)
|
||||||
row = self.controller.store.read(binding.job_id)
|
row = self.controller.store.read(submitted.job_id)
|
||||||
state = JobState(row["state"])
|
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||||
if state == JobState.AUTHORIZED:
|
|
||||||
self.controller.lease_job(
|
self.controller.lease_job(
|
||||||
binding.job_id,
|
submitted.job_id,
|
||||||
lease_id=f"lease-{binding.job_id}",
|
lease_id=f"lease-{submitted.job_id}",
|
||||||
lineage_anchor_oid=request.base_oid,
|
lineage_anchor_oid=request.base_oid,
|
||||||
current_head_oid=request.expected_branch_oid,
|
current_head_oid=request.expected_branch_oid,
|
||||||
generation=request.generation,
|
generation=request.generation,
|
||||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||||
expected_revision=int(row["revision"]),
|
expected_revision=int(row["revision"]),
|
||||||
)
|
)
|
||||||
row = self.controller.store.read(binding.job_id)
|
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||||
state = JobState(row["state"])
|
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||||
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")
|
|
||||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
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):
|
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")
|
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:
|
if authority_oid != new_oid:
|
||||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||||
self.controller.advance(
|
self.controller.advance(
|
||||||
binding.job_id,
|
submitted.job_id,
|
||||||
JobState.LOCAL_VERIFIED,
|
JobState.LOCAL_VERIFIED,
|
||||||
side_effect_state="LOCAL_VERIFIED",
|
side_effect_state="LOCAL_VERIFIED",
|
||||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
self.controller.advance(submitted.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.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||||
except Exception:
|
except Exception:
|
||||||
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||||
result = self._finish_remote_verification(
|
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||||
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
|
|
||||||
finally:
|
finally:
|
||||||
if projected:
|
if projected:
|
||||||
self.credential.revoke()
|
self.credential.revoke()
|
||||||
@@ -629,14 +525,7 @@ class ContinuationExecutor:
|
|||||||
)
|
)
|
||||||
return result.as_dict()
|
return result.as_dict()
|
||||||
|
|
||||||
def _finish_remote_verification(
|
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
authority_oid: str,
|
|
||||||
new_oid: str,
|
|
||||||
*,
|
|
||||||
before_complete: Callable[[str], None] | None = None,
|
|
||||||
) -> dict[str, object]:
|
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
branch = row["branch"]
|
branch = row["branch"]
|
||||||
request = json.loads(row["request_json"])["binding"]
|
request = json.loads(row["request_json"])["binding"]
|
||||||
@@ -653,20 +542,8 @@ class ContinuationExecutor:
|
|||||||
"reconciliation_decision": decision.action.value,
|
"reconciliation_decision": decision.action.value,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
}
|
}
|
||||||
self.controller.advance(
|
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||||
job_id,
|
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||||
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()
|
|
||||||
|
|
||||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||||
row = self.controller.store.read(job_id)
|
row = self.controller.store.read(job_id)
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class WriterLeaseRequestV2:
|
|||||||
target_branch: str
|
target_branch: str
|
||||||
generation: int
|
generation: int
|
||||||
lease_seconds: int = 900
|
lease_seconds: int = 900
|
||||||
job_id: str = ""
|
|
||||||
|
|
||||||
schema = "aios.task-writer-lease.v2"
|
schema = "aios.task-writer-lease.v2"
|
||||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.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:
|
def validate(self, probe: BranchProbe) -> None:
|
||||||
if not self.task_id or self.repository != "aios/aios":
|
if not self.task_id or self.repository != "aios/aios":
|
||||||
raise ContinuationError("request_identity_invalid")
|
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"}:
|
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||||
raise ContinuationError("branch_mode_invalid")
|
raise ContinuationError("branch_mode_invalid")
|
||||||
_oid(self.base_oid, "base_oid")
|
_oid(self.base_oid, "base_oid")
|
||||||
|
|||||||
@@ -27,19 +27,6 @@ class ServiceUnavailable(JobError):
|
|||||||
pass
|
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):
|
class BindingConflict(JobError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -79,14 +66,6 @@ def utc_now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
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:
|
def canonical_json(value: Any) -> bytes:
|
||||||
return (
|
return (
|
||||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
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,
|
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)
|
created_at TEXT NOT NULL, FOREIGN KEY(job_id) REFERENCES jobs(job_id)
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS continuation_cycles (
|
INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', '1');
|
||||||
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');
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -433,16 +404,13 @@ class GitOpsJobController:
|
|||||||
self.store._mirror(result)
|
self.store._mirror(result)
|
||||||
return self.store._receipt(result, duplicate=True)
|
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:
|
if len(current_head_oid) != 40 or generation < 1:
|
||||||
raise JobError("LEASE_BINDING_INVALID")
|
raise JobError("LEASE_BINDING_INVALID")
|
||||||
try:
|
try:
|
||||||
with self.store._connect() as connection:
|
with self.store._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
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))
|
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))
|
|
||||||
if updated.rowcount != 1:
|
if updated.rowcount != 1:
|
||||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||||
connection.commit()
|
connection.commit()
|
||||||
@@ -451,271 +419,6 @@ class GitOpsJobController:
|
|||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from 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:
|
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(
|
return self._advance(
|
||||||
job_id,
|
job_id,
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from contextlib import contextmanager
|
|
||||||
import fcntl
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -11,11 +9,6 @@ import re
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
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}$")
|
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}$")
|
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}
|
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"
|
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:
|
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:
|
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]:
|
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||||
legacy = self.job_root / _legacy_safe(task_id)
|
root = self.job_root / _safe(task_id); store = JobStore(root, payload_root=root / "payloads"); return store, GitOpsJobController(store)
|
||||||
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)
|
|
||||||
|
|
||||||
def _metadata(self, task_id: str) -> Path:
|
def _metadata(self, task_id: str) -> Path:
|
||||||
return self.task_meta_root / (_task_key(task_id) + ".json")
|
return self.task_meta_root / (_safe(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)
|
|
||||||
|
|
||||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||||
path = self._metadata(task_id)
|
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
|
if not path.is_file() or path.is_symlink(): return None
|
||||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
return 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)
|
|
||||||
|
|
||||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
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)
|
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]:
|
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")}
|
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 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")
|
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||||
existing = self._read_existing(values["task_id"])
|
existing = self._read_existing(values["task_id"])
|
||||||
if existing is not None:
|
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")
|
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description")): 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")
|
|
||||||
return existing.response(idempotent=True)
|
return existing.response(idempotent=True)
|
||||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
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"])
|
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")
|
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||||
else:
|
else:
|
||||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||||
parent_task_id = str(request.get("parent_task_id") or "")
|
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||||
parent_job_id = str(request.get("parent_job_id") or "")
|
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||||
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")
|
|
||||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||||
_, controller = self._store(values["task_id"])
|
_, 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)
|
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))
|
|
||||||
@@ -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"],
|
|
||||||
}
|
|
||||||
@@ -964,7 +964,6 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
required = {
|
required = {
|
||||||
"schema",
|
"schema",
|
||||||
"task_id",
|
"task_id",
|
||||||
"job_id",
|
|
||||||
"repository",
|
"repository",
|
||||||
"branch_mode",
|
"branch_mode",
|
||||||
"base_oid",
|
"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)
|
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
task_id=str(raw["task_id"]),
|
task_id=str(raw["task_id"]),
|
||||||
job_id=str(raw["job_id"]),
|
|
||||||
repository=str(raw["repository"]),
|
repository=str(raw["repository"]),
|
||||||
branch_mode=str(raw["branch_mode"]),
|
branch_mode=str(raw["branch_mode"]),
|
||||||
base_oid=str(raw["base_oid"]),
|
base_oid=str(raw["base_oid"]),
|
||||||
@@ -990,7 +988,6 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
|||||||
return {
|
return {
|
||||||
"schema": WriterLeaseRequestV2.schema,
|
"schema": WriterLeaseRequestV2.schema,
|
||||||
"task_id": request.task_id,
|
"task_id": request.task_id,
|
||||||
"job_id": request.job_id,
|
|
||||||
"repository": request.repository,
|
"repository": request.repository,
|
||||||
"branch_mode": request.branch_mode,
|
"branch_mode": request.branch_mode,
|
||||||
"base_oid": request.base_oid,
|
"base_oid": request.base_oid,
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import datetime as dt
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Iterable, Mapping
|
|
||||||
|
|
||||||
UTC = dt.timezone.utc
|
|
||||||
_ALLOWED_CONFIDENTIALITY = frozenset({"public", "tenant", "project", "private", "run"})
|
|
||||||
_PRIVILEGED = frozenset({"*", "admin", "system.admin", "message.admin", "task.admin"})
|
|
||||||
|
|
||||||
|
|
||||||
class HandoffError(RuntimeError):
|
|
||||||
code = "HANDOFF_ERROR"
|
|
||||||
|
|
||||||
|
|
||||||
class MessageScopeMismatch(HandoffError):
|
|
||||||
code = "MESSAGE_SCOPE_MISMATCH_DENY"
|
|
||||||
|
|
||||||
|
|
||||||
class ConfidentialityScopeDenied(HandoffError):
|
|
||||||
code = "MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"
|
|
||||||
|
|
||||||
|
|
||||||
class MessageExpired(HandoffError):
|
|
||||||
code = "MESSAGE_EXPIRED"
|
|
||||||
|
|
||||||
|
|
||||||
class SequenceGap(HandoffError):
|
|
||||||
code = "MESSAGE_SEQUENCE_GAP_DETECT"
|
|
||||||
|
|
||||||
|
|
||||||
class SequenceConflict(HandoffError):
|
|
||||||
code = "MESSAGE_SEQUENCE_CONFLICT"
|
|
||||||
|
|
||||||
|
|
||||||
class IdempotencyConflict(HandoffError):
|
|
||||||
code = "MESSAGE_IDEMPOTENCY_CONFLICT"
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> dt.datetime:
|
|
||||||
return dt.datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
def _iso(value: dt.datetime | str | None) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=UTC)
|
|
||||||
return parsed.astimezone(UTC).isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical(value: Any) -> str:
|
|
||||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
|
||||||
|
|
||||||
|
|
||||||
def _sha(value: Any) -> str:
|
|
||||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean(value: Any) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
text = str(value).strip()
|
|
||||||
return text or None
|
|
||||||
|
|
||||||
|
|
||||||
def _tuple_scope(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
|
||||||
if value is None:
|
|
||||||
return ()
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = value.split(",")
|
|
||||||
return tuple(sorted({str(item).strip() for item in value if str(item).strip()}))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class DeliveryContext:
|
|
||||||
tenant_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
project_id: str | None = None
|
|
||||||
conversation_id: str | None = None
|
|
||||||
run_id: str | None = None
|
|
||||||
capabilities: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_actor(cls, actor: Any) -> "DeliveryContext":
|
|
||||||
def get(name: str) -> Any:
|
|
||||||
if isinstance(actor, Mapping):
|
|
||||||
return actor.get(name)
|
|
||||||
return getattr(actor, name, None)
|
|
||||||
return cls(
|
|
||||||
tenant_id=_clean(get("tenant_id")),
|
|
||||||
user_id=_clean(get("user_id")),
|
|
||||||
project_id=_clean(get("project_id")),
|
|
||||||
conversation_id=_clean(get("conversation_id")),
|
|
||||||
run_id=_clean(get("run_id")),
|
|
||||||
capabilities=_tuple_scope(get("capabilities") or get("capability_scope")),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def privileged(self) -> bool:
|
|
||||||
return bool(set(self.capabilities) & _PRIVILEGED)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
|
||||||
class MessageEnvelope:
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
message_type: str
|
|
||||||
payload: Mapping[str, Any]
|
|
||||||
sequence: int
|
|
||||||
idempotency_key: str
|
|
||||||
tenant_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
project_id: str | None = None
|
|
||||||
conversation_id: str | None = None
|
|
||||||
task_id: str | None = None
|
|
||||||
run_id: str | None = None
|
|
||||||
causation_id: str | None = None
|
|
||||||
capability_scope: tuple[str, ...] = ()
|
|
||||||
confidentiality_scope: str = "private"
|
|
||||||
expires_at: str | None = None
|
|
||||||
created_at: str = dataclasses.field(default_factory=lambda: _iso(_utcnow()) or "")
|
|
||||||
envelope_id: str = ""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
|
||||||
for field in ("source", "target", "message_type", "idempotency_key", "tenant_id", "user_id",
|
|
||||||
"project_id", "conversation_id", "task_id", "run_id", "causation_id"):
|
|
||||||
object.__setattr__(self, field, _clean(getattr(self, field)) if field not in {
|
|
||||||
"source", "target", "message_type", "idempotency_key"
|
|
||||||
} else (_clean(getattr(self, field)) or ""))
|
|
||||||
object.__setattr__(self, "capability_scope", _tuple_scope(self.capability_scope))
|
|
||||||
object.__setattr__(self, "confidentiality_scope", (_clean(self.confidentiality_scope) or "private").lower())
|
|
||||||
object.__setattr__(self, "expires_at", _iso(self.expires_at))
|
|
||||||
object.__setattr__(self, "created_at", _iso(self.created_at) or "")
|
|
||||||
if not self.source or not self.target or not self.message_type or not self.idempotency_key:
|
|
||||||
raise ValueError("MESSAGE_ENVELOPE_IDENTITY_REQUIRED")
|
|
||||||
if not isinstance(self.sequence, int) or self.sequence < 1:
|
|
||||||
raise ValueError("MESSAGE_SEQUENCE_MUST_BE_POSITIVE")
|
|
||||||
if self.confidentiality_scope not in _ALLOWED_CONFIDENTIALITY:
|
|
||||||
raise ValueError("MESSAGE_CONFIDENTIALITY_SCOPE_INVALID")
|
|
||||||
if self.confidentiality_scope != "public" and self.tenant_id is None:
|
|
||||||
raise ValueError("MESSAGE_TENANT_REQUIRED")
|
|
||||||
if self.confidentiality_scope in {"project", "private", "run"} and self.project_id is None:
|
|
||||||
raise ValueError("MESSAGE_PROJECT_REQUIRED")
|
|
||||||
if self.confidentiality_scope in {"private", "run"} and self.user_id is None:
|
|
||||||
raise ValueError("MESSAGE_USER_REQUIRED")
|
|
||||||
if self.confidentiality_scope == "run" and self.run_id is None:
|
|
||||||
raise ValueError("MESSAGE_RUN_REQUIRED")
|
|
||||||
expected = stable_event_id(
|
|
||||||
source=self.source, target=self.target, message_type=self.message_type, payload=self.payload,
|
|
||||||
tenant_id=self.tenant_id, user_id=self.user_id, project_id=self.project_id,
|
|
||||||
conversation_id=self.conversation_id, task_id=self.task_id, run_id=self.run_id,
|
|
||||||
causation_id=self.causation_id, sequence=self.sequence, idempotency_key=self.idempotency_key,
|
|
||||||
)
|
|
||||||
if self.envelope_id and self.envelope_id != expected:
|
|
||||||
raise ValueError("MESSAGE_ENVELOPE_ID_MISMATCH")
|
|
||||||
object.__setattr__(self, "envelope_id", expected)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def payload_sha256(self) -> str:
|
|
||||||
return _sha(self.payload)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stream_key(self) -> str:
|
|
||||||
return _sha({
|
|
||||||
"tenant_id": self.tenant_id, "source": self.source, "target": self.target,
|
|
||||||
"conversation_id": self.conversation_id, "task_id": self.task_id, "run_id": self.run_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
def is_expired(self, *, now: dt.datetime | None = None) -> bool:
|
|
||||||
if self.expires_at is None:
|
|
||||||
return False
|
|
||||||
return dt.datetime.fromisoformat(self.expires_at) <= (now or _utcnow()).astimezone(UTC)
|
|
||||||
|
|
||||||
def as_record(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"envelope_id": self.envelope_id, "stream_key": self.stream_key, "source": self.source,
|
|
||||||
"target": self.target, "message_type": self.message_type, "payload_json": _canonical(self.payload),
|
|
||||||
"payload_sha256": self.payload_sha256, "tenant_id": self.tenant_id, "user_id": self.user_id,
|
|
||||||
"project_id": self.project_id, "conversation_id": self.conversation_id, "task_id": self.task_id,
|
|
||||||
"run_id": self.run_id, "causation_id": self.causation_id, "sequence": self.sequence,
|
|
||||||
"capability_scope_json": _canonical(self.capability_scope),
|
|
||||||
"confidentiality_scope": self.confidentiality_scope, "idempotency_key": self.idempotency_key,
|
|
||||||
"expires_at": self.expires_at, "created_at": self.created_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def stable_event_id(*, source: Any, target: Any, message_type: Any, payload: Any,
|
|
||||||
tenant_id: Any = None, user_id: Any = None, project_id: Any = None,
|
|
||||||
conversation_id: Any = None, task_id: Any = None, run_id: Any = None,
|
|
||||||
causation_id: Any = None, sequence: Any = None, idempotency_key: Any = None) -> str:
|
|
||||||
return _sha({
|
|
||||||
"source": _clean(source), "target": _clean(target), "message_type": _clean(message_type),
|
|
||||||
"payload_sha256": _sha(payload), "tenant_id": _clean(tenant_id), "user_id": _clean(user_id),
|
|
||||||
"project_id": _clean(project_id), "conversation_id": _clean(conversation_id),
|
|
||||||
"task_id": _clean(task_id), "run_id": _clean(run_id), "causation_id": _clean(causation_id),
|
|
||||||
"sequence": sequence, "idempotency_key": _clean(idempotency_key),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def bind_envelope_scope(envelope: MessageEnvelope, actor: Any) -> MessageEnvelope:
|
|
||||||
ctx = DeliveryContext.from_actor(actor)
|
|
||||||
if ctx.privileged:
|
|
||||||
return envelope
|
|
||||||
if ctx.tenant_id is None:
|
|
||||||
if envelope.tenant_id is not None:
|
|
||||||
raise MessageScopeMismatch(MessageScopeMismatch.code)
|
|
||||||
return envelope
|
|
||||||
updates: dict[str, Any] = {}
|
|
||||||
for field in ("tenant_id", "user_id", "project_id", "conversation_id", "run_id"):
|
|
||||||
trusted = getattr(ctx, field)
|
|
||||||
requested = getattr(envelope, field)
|
|
||||||
if requested is not None and requested != trusted:
|
|
||||||
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:{field}")
|
|
||||||
if requested is None and trusted is not None:
|
|
||||||
updates[field] = trusted
|
|
||||||
if not updates:
|
|
||||||
return envelope
|
|
||||||
data = dataclasses.asdict(envelope)
|
|
||||||
data.update(updates)
|
|
||||||
data["envelope_id"] = ""
|
|
||||||
return MessageEnvelope(**data)
|
|
||||||
|
|
||||||
|
|
||||||
def authorize_delivery(actor: Any, envelope: MessageEnvelope) -> None:
|
|
||||||
ctx = DeliveryContext.from_actor(actor)
|
|
||||||
if ctx.privileged:
|
|
||||||
return
|
|
||||||
if not set(envelope.capability_scope).issubset(set(ctx.capabilities)):
|
|
||||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:capability")
|
|
||||||
if envelope.confidentiality_scope == "public":
|
|
||||||
return
|
|
||||||
if ctx.tenant_id != envelope.tenant_id:
|
|
||||||
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:tenant_id")
|
|
||||||
if envelope.confidentiality_scope in {"project", "private", "run"} and ctx.project_id != envelope.project_id:
|
|
||||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:project_id")
|
|
||||||
if envelope.confidentiality_scope in {"private", "run"} and ctx.user_id != envelope.user_id:
|
|
||||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:user_id")
|
|
||||||
if envelope.confidentiality_scope == "run" and ctx.run_id != envelope.run_id:
|
|
||||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:run_id")
|
|
||||||
|
|
||||||
|
|
||||||
_SCHEMA = """
|
|
||||||
CREATE TABLE IF NOT EXISTS message_handoff (
|
|
||||||
envelope_id TEXT PRIMARY KEY,
|
|
||||||
stream_key TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
message_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
payload_sha256 TEXT NOT NULL,
|
|
||||||
tenant_id TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
project_id TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
task_id TEXT,
|
|
||||||
run_id TEXT,
|
|
||||||
causation_id TEXT,
|
|
||||||
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
|
||||||
capability_scope_json TEXT NOT NULL,
|
|
||||||
confidentiality_scope TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
expires_at TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
||||||
delivered_at TEXT,
|
|
||||||
acked_at TEXT,
|
|
||||||
UNIQUE(stream_key, idempotency_key),
|
|
||||||
UNIQUE(stream_key, sequence)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
|
||||||
ON message_handoff(target, status, created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
|
||||||
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class DurableHandoffStore:
|
|
||||||
def __init__(self, path: str | Path) -> None:
|
|
||||||
self.path = Path(path)
|
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.executescript(_SCHEMA)
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
conn = sqlite3.connect(self.path, timeout=30.0)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
|
||||||
conn.execute("PRAGMA busy_timeout=30000")
|
|
||||||
return conn
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _from_row(row: sqlite3.Row) -> MessageEnvelope:
|
|
||||||
return MessageEnvelope(
|
|
||||||
source=row["source"], target=row["target"], message_type=row["message_type"],
|
|
||||||
payload=json.loads(row["payload_json"]), tenant_id=row["tenant_id"], user_id=row["user_id"],
|
|
||||||
project_id=row["project_id"], conversation_id=row["conversation_id"], task_id=row["task_id"],
|
|
||||||
run_id=row["run_id"], causation_id=row["causation_id"], sequence=int(row["sequence"]),
|
|
||||||
capability_scope=tuple(json.loads(row["capability_scope_json"])),
|
|
||||||
confidentiality_scope=row["confidentiality_scope"], idempotency_key=row["idempotency_key"],
|
|
||||||
expires_at=row["expires_at"], created_at=row["created_at"], envelope_id=row["envelope_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def put(self, envelope: MessageEnvelope) -> tuple[str, MessageEnvelope]:
|
|
||||||
if envelope.is_expired():
|
|
||||||
raise MessageExpired(MessageExpired.code)
|
|
||||||
record = envelope.as_record()
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute("BEGIN IMMEDIATE")
|
|
||||||
replay = conn.execute(
|
|
||||||
"SELECT * FROM message_handoff WHERE stream_key=? AND idempotency_key=?",
|
|
||||||
(envelope.stream_key, envelope.idempotency_key),
|
|
||||||
).fetchone()
|
|
||||||
if replay is not None:
|
|
||||||
existing = self._from_row(replay)
|
|
||||||
if existing.envelope_id != envelope.envelope_id or existing.payload_sha256 != envelope.payload_sha256:
|
|
||||||
raise IdempotencyConflict(IdempotencyConflict.code)
|
|
||||||
conn.commit()
|
|
||||||
return "REPLAY", existing
|
|
||||||
seq = conn.execute(
|
|
||||||
"SELECT * FROM message_handoff WHERE stream_key=? AND sequence=?",
|
|
||||||
(envelope.stream_key, envelope.sequence),
|
|
||||||
).fetchone()
|
|
||||||
if seq is not None:
|
|
||||||
existing = self._from_row(seq)
|
|
||||||
if existing.envelope_id == envelope.envelope_id:
|
|
||||||
conn.commit()
|
|
||||||
return "REPLAY", existing
|
|
||||||
raise SequenceConflict(SequenceConflict.code)
|
|
||||||
maximum = int(conn.execute(
|
|
||||||
"SELECT COALESCE(MAX(sequence),0) FROM message_handoff WHERE stream_key=?",
|
|
||||||
(envelope.stream_key,),
|
|
||||||
).fetchone()[0])
|
|
||||||
expected = maximum + 1
|
|
||||||
if envelope.sequence != expected:
|
|
||||||
raise SequenceGap(f"{SequenceGap.code}:expected={expected}:actual={envelope.sequence}")
|
|
||||||
columns = tuple(record)
|
|
||||||
placeholders = ",".join("?" for _ in columns)
|
|
||||||
conn.execute(
|
|
||||||
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({placeholders})",
|
|
||||||
tuple(record[column] for column in columns),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return "CREATED", envelope
|
|
||||||
|
|
||||||
def get(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
|
||||||
with self._connect() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM message_handoff WHERE envelope_id=?", (envelope_id,)).fetchone()
|
|
||||||
if row is None:
|
|
||||||
raise KeyError(envelope_id)
|
|
||||||
envelope = self._from_row(row)
|
|
||||||
if envelope.is_expired():
|
|
||||||
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope_id,))
|
|
||||||
conn.commit()
|
|
||||||
raise MessageExpired(MessageExpired.code)
|
|
||||||
authorize_delivery(actor, envelope)
|
|
||||||
return envelope
|
|
||||||
|
|
||||||
def pending(self, *, target: str, actor: Any, limit: int = 100) -> list[MessageEnvelope]:
|
|
||||||
if not 1 <= limit <= 1000:
|
|
||||||
raise ValueError("MESSAGE_PENDING_LIMIT_INVALID")
|
|
||||||
visible: list[MessageEnvelope] = []
|
|
||||||
with self._connect() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM message_handoff WHERE target=? AND status='PENDING' ORDER BY created_at, sequence LIMIT ?",
|
|
||||||
(target, limit),
|
|
||||||
).fetchall()
|
|
||||||
for row in rows:
|
|
||||||
envelope = self._from_row(row)
|
|
||||||
if envelope.is_expired():
|
|
||||||
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope.envelope_id,))
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
authorize_delivery(actor, envelope)
|
|
||||||
except HandoffError:
|
|
||||||
continue
|
|
||||||
visible.append(envelope)
|
|
||||||
conn.commit()
|
|
||||||
return visible
|
|
||||||
|
|
||||||
def mark_delivered(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
|
||||||
envelope = self.get(envelope_id, actor=actor)
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE message_handoff SET status='DELIVERED', delivered_at=? "
|
|
||||||
"WHERE envelope_id=? AND status='PENDING'",
|
|
||||||
(_iso(_utcnow()), envelope_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return envelope
|
|
||||||
|
|
||||||
def acknowledge(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
|
||||||
envelope = self.get(envelope_id, actor=actor)
|
|
||||||
with self._connect() as conn:
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE message_handoff SET status='ACKED', acked_at=? WHERE envelope_id=?",
|
|
||||||
(_iso(_utcnow()), envelope_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
return envelope
|
|
||||||
|
|
||||||
|
|
||||||
class HandoffFabric:
|
|
||||||
def __init__(self, store: DurableHandoffStore) -> None:
|
|
||||||
self.store = store
|
|
||||||
|
|
||||||
def publish(self, envelope: MessageEnvelope, *, actor: Any | None = None) -> tuple[str, MessageEnvelope]:
|
|
||||||
return self.store.put(bind_envelope_scope(envelope, actor) if actor is not None else envelope)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from message_handoff import MessageEnvelope
|
|
||||||
|
|
||||||
|
|
||||||
def _get(value: Any, *names: str, default: Any = None) -> Any:
|
|
||||||
for name in names:
|
|
||||||
if isinstance(value, Mapping) and name in value:
|
|
||||||
return value[name]
|
|
||||||
if hasattr(value, name):
|
|
||||||
return getattr(value, name)
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def agent_event_to_envelope(event: Any, *, target: str | None = None, sequence: int = 1,
|
|
||||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
|
||||||
payload = _get(event, "payload", "data", default={}) or {}
|
|
||||||
target = target or _get(event, "target", "recipient")
|
|
||||||
if not target:
|
|
||||||
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
|
||||||
return MessageEnvelope(
|
|
||||||
source=str(_get(event, "source", "producer", "agent_id", default="agent-event")),
|
|
||||||
target=str(target),
|
|
||||||
message_type=str(_get(event, "event_type", "type", "message_type", default="agent.event")),
|
|
||||||
payload=dict(payload),
|
|
||||||
sequence=sequence,
|
|
||||||
idempotency_key=idempotency_key or str(_get(event, "id", "event_id", default="")) or f"agent-event:{sequence}",
|
|
||||||
causation_id=_get(event, "causation_id"),
|
|
||||||
**scope,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def taskplane_event_to_envelope(event: Any, *, target: str, sequence: int = 1,
|
|
||||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
|
||||||
data = _get(event, "data", "payload", default={}) or {}
|
|
||||||
event_id = str(_get(event, "id", "event_id", default=""))
|
|
||||||
return MessageEnvelope(
|
|
||||||
source=str(_get(event, "source", default="task-plane")),
|
|
||||||
target=target,
|
|
||||||
message_type=str(_get(event, "type", "event_type", default="task.event")),
|
|
||||||
payload=dict(data),
|
|
||||||
sequence=sequence,
|
|
||||||
idempotency_key=idempotency_key or event_id or f"task-event:{sequence}",
|
|
||||||
causation_id=event_id or None,
|
|
||||||
**scope,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def legacy_message_to_envelope(message: Any, *, target: str | None = None, sequence: int = 1,
|
|
||||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
|
||||||
payload = _get(message, "payload", "data", "body", "content", default={})
|
|
||||||
if not isinstance(payload, Mapping):
|
|
||||||
payload = {"value": payload}
|
|
||||||
target = target or _get(message, "target", "recipient", "receiver")
|
|
||||||
if not target:
|
|
||||||
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
|
||||||
source = _get(message, "source", "sender", "agent_id", default="legacy-message-bus")
|
|
||||||
kind = _get(message, "message_type", "type", "kind", default="legacy.message")
|
|
||||||
identifier = _get(message, "id", "message_id", "event_id")
|
|
||||||
return MessageEnvelope(
|
|
||||||
source=str(source), target=str(target), message_type=str(kind), payload=dict(payload),
|
|
||||||
sequence=sequence,
|
|
||||||
idempotency_key=idempotency_key or (str(identifier) if identifier else f"legacy:{sequence}"),
|
|
||||||
causation_id=str(identifier) if identifier else None,
|
|
||||||
**scope,
|
|
||||||
)
|
|
||||||
@@ -34,7 +34,6 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .policy import PolicyEngine
|
from .policy import PolicyEngine
|
||||||
from .store import TaskPlaneStore
|
from .store import TaskPlaneStore
|
||||||
from .scope import bind_task_scope
|
|
||||||
from .worker_registry import WorkerRegistry
|
from .worker_registry import WorkerRegistry
|
||||||
|
|
||||||
|
|
||||||
@@ -69,54 +68,28 @@ class TaskPlaneService:
|
|||||||
self.delegation = DelegationEngine(store)
|
self.delegation = DelegationEngine(store)
|
||||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||||
|
|
||||||
def _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord:
|
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||||
task = self.store.get_task(task_id, self.policy.actor_context(actor))
|
|
||||||
self.policy.assert_allowed(action, actor, task=task)
|
|
||||||
return task
|
|
||||||
|
|
||||||
def create_task(
|
|
||||||
self,
|
|
||||||
body: dict[str, Any],
|
|
||||||
idempotency_key: str | None = None,
|
|
||||||
*,
|
|
||||||
actor_identity: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if body.get("parent_task_id"):
|
if body.get("parent_task_id"):
|
||||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||||
issuer = body.get("issuer", {})
|
issuer = body.get("issuer", {})
|
||||||
declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||||
actor = actor_identity or declared_actor
|
|
||||||
self.policy.assert_allowed("create", actor)
|
self.policy.assert_allowed("create", actor)
|
||||||
try:
|
try:
|
||||||
plan = TaskPlan.from_dict(body)
|
plan = TaskPlan.from_dict(body)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid task plan") from exc
|
raise ValidationError("invalid task plan") from exc
|
||||||
if actor_identity is not None:
|
|
||||||
plan = bind_task_scope(
|
|
||||||
plan, self.policy.actor_context(actor_identity), public_create=True
|
|
||||||
)
|
|
||||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||||
return task.to_dict()
|
return task.to_dict()
|
||||||
|
|
||||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
context = None
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor)
|
self.policy.assert_allowed("read", actor)
|
||||||
context = self.policy.actor_context(actor)
|
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||||
return [
|
|
||||||
task.to_dict(include_task=False)
|
|
||||||
for task in self.store.list_tasks(context)
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
context = None
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self.policy.assert_allowed("read", actor)
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
context = self.policy.actor_context(actor)
|
return self.store.get_task(task_id).to_dict()
|
||||||
task = self.store.get_task(task_id, context)
|
|
||||||
if actor is not None:
|
|
||||||
self.policy.assert_allowed("read", actor, task=task)
|
|
||||||
return task.to_dict()
|
|
||||||
|
|
||||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
@@ -190,8 +163,9 @@ class TaskPlaneService:
|
|||||||
if not worker_id:
|
if not worker_id:
|
||||||
raise ValidationError("worker_id required")
|
raise ValidationError("worker_id required")
|
||||||
|
|
||||||
task = self._authorized_task(task_id, actor, "claim")
|
task = self.store.get_task(task_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
||||||
|
self.policy.assert_allowed("claim", actor, task=task)
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||||
)
|
)
|
||||||
@@ -210,9 +184,10 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self._authorized_task(task_id, actor, "heartbeat")
|
task = self.store.get_task(task_id)
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
|
self.policy.assert_allowed("heartbeat", actor, task=task)
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
lease_seconds = _bounded_int(
|
lease_seconds = _bounded_int(
|
||||||
@@ -231,9 +206,10 @@ class TaskPlaneService:
|
|||||||
message = body.get("message")
|
message = body.get("message")
|
||||||
if progress is None:
|
if progress is None:
|
||||||
raise ValidationError("progress required")
|
raise ValidationError("progress required")
|
||||||
task = self._authorized_task(task_id, actor, "progress")
|
task = self.store.get_task(task_id)
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
|
self.policy.assert_allowed("progress", actor, task=task)
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
if message not in {None, ""} or contains_secret_text(stage):
|
if message not in {None, ""} or contains_secret_text(stage):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
@@ -258,7 +234,7 @@ class TaskPlaneService:
|
|||||||
if not claim_id:
|
if not claim_id:
|
||||||
raise ValidationError("claim_id required")
|
raise ValidationError("claim_id required")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
task = self._authorized_task(task_id, actor, "result")
|
task = self.store.get_task(task_id)
|
||||||
|
|
||||||
status = body.get("status", "COMPLETED")
|
status = body.get("status", "COMPLETED")
|
||||||
outputs = body.get("outputs", [])
|
outputs = body.get("outputs", [])
|
||||||
@@ -271,6 +247,7 @@ class TaskPlaneService:
|
|||||||
raise ValidationError("evidence items must be objects")
|
raise ValidationError("evidence items must be objects")
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||||
|
self.policy.assert_allowed("result", actor, task=task)
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
if task.task_plan.data_classification == "credential-secret":
|
if task.task_plan.data_classification == "credential-secret":
|
||||||
@@ -349,7 +326,8 @@ class TaskPlaneService:
|
|||||||
reason = body.get("reason", "failed")
|
reason = body.get("reason", "failed")
|
||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||||
self.policy.assert_allowed("fail", actor)
|
self.policy.assert_allowed("fail", actor)
|
||||||
task = self._authorized_task(task_id, actor, "fail")
|
task = self.store.get_task(task_id)
|
||||||
|
self.policy.assert_allowed("fail", actor, task=task)
|
||||||
claim = self.store.get_claim(claim_id)
|
claim = self.store.get_claim(claim_id)
|
||||||
if claim.worker_identity != actor:
|
if claim.worker_identity != actor:
|
||||||
raise ForbiddenError("claim identity mismatch")
|
raise ForbiddenError("claim identity mismatch")
|
||||||
@@ -361,7 +339,8 @@ class TaskPlaneService:
|
|||||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
||||||
self.policy.assert_allowed("cancel", actor)
|
self.policy.assert_allowed("cancel", actor)
|
||||||
reason = body.get("reason", "cancelled")
|
reason = body.get("reason", "cancelled")
|
||||||
task = self._authorized_task(task_id, actor, "cancel")
|
task = self.store.get_task(task_id)
|
||||||
|
self.policy.assert_allowed("cancel", actor, task=task)
|
||||||
updated = self.store.cancel_task_with_event(
|
updated = self.store.cancel_task_with_event(
|
||||||
task_id, task.revision, reason=reason
|
task_id, task.revision, reason=reason
|
||||||
)
|
)
|
||||||
@@ -370,7 +349,8 @@ class TaskPlaneService:
|
|||||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
actor = body.get("actor_identity") or "owner"
|
actor = body.get("actor_identity") or "owner"
|
||||||
self.policy.assert_allowed("delegate", actor)
|
self.policy.assert_allowed("delegate", actor)
|
||||||
parent = self._authorized_task(task_id, actor, "delegate")
|
parent = self.store.get_task(task_id)
|
||||||
|
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||||
if "task" in body:
|
if "task" in body:
|
||||||
try:
|
try:
|
||||||
child_plan = TaskPlan.from_dict(body["task"])
|
child_plan = TaskPlan.from_dict(body["task"])
|
||||||
@@ -384,30 +364,24 @@ class TaskPlaneService:
|
|||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValidationError("invalid child task plan") from exc
|
raise ValidationError("invalid child task plan") from exc
|
||||||
child_plan.parent_task_id = task_id
|
child_plan.parent_task_id = task_id
|
||||||
child_plan = bind_task_scope(
|
|
||||||
child_plan, self.policy.actor_context(actor), public_create=False
|
|
||||||
)
|
|
||||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||||
return child.to_dict()
|
return child.to_dict()
|
||||||
|
|
||||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self._authorized_task(task_id, actor, "audit")
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
else:
|
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.store.list_events(task_id)
|
return self.store.list_events(task_id)
|
||||||
|
|
||||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self._authorized_task(task_id, actor, "audit")
|
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||||
else:
|
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.evidence.list(task_id)
|
return self.evidence.list(task_id)
|
||||||
|
|
||||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
self._authorized_task(task_id, actor, "read")
|
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||||
else:
|
|
||||||
self.store.get_task(task_id)
|
self.store.get_task(task_id)
|
||||||
return self.store.list_children(task_id)
|
return self.store.list_children(task_id)
|
||||||
|
|
||||||
@@ -532,11 +506,7 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
|||||||
):
|
):
|
||||||
raise ForbiddenError("issuer identity mismatch")
|
raise ForbiddenError("issuer identity mismatch")
|
||||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||||
return 201, self.service.create_task(
|
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||||
body or {},
|
|
||||||
headers.get("idempotency-key"),
|
|
||||||
actor_identity=identity,
|
|
||||||
)
|
|
||||||
if body.get("actor_identity") not in {None, identity}:
|
if body.get("actor_identity") not in {None, identity}:
|
||||||
raise ForbiddenError("actor identity mismatch")
|
raise ForbiddenError("actor identity mismatch")
|
||||||
body["actor_identity"] = identity
|
body["actor_identity"] = identity
|
||||||
|
|||||||
@@ -106,8 +106,3 @@ class DurableOutboxDispatcher:
|
|||||||
receipt=str(receipt) if receipt else None,
|
receipt=str(receipt) if receipt else None,
|
||||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
error=None if receipt else f"{adapter.name} receipt unavailable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# R4.2 P0-B compatibility adapter.
|
|
||||||
def to_handoff_envelope(event, *, target, sequence=1, idempotency_key=None, **scope):
|
|
||||||
from message_handoff_adapters import taskplane_event_to_envelope
|
|
||||||
return taskplane_event_to_envelope(event, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .scope import normalize_task_scope
|
|
||||||
|
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"secret",
|
"secret",
|
||||||
@@ -68,12 +66,6 @@ TASK_PLAN_KEYS = {
|
|||||||
"allowed_input_paths",
|
"allowed_input_paths",
|
||||||
"allowed_output_paths",
|
"allowed_output_paths",
|
||||||
"output_policy",
|
"output_policy",
|
||||||
"scope_type",
|
|
||||||
"tenant_id",
|
|
||||||
"user_id",
|
|
||||||
"project_id",
|
|
||||||
"conversation_id",
|
|
||||||
"run_id",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -245,12 +237,6 @@ class TaskPlan:
|
|||||||
schema_version: int = 1
|
schema_version: int = 1
|
||||||
task_id: str | None = None
|
task_id: str | None = None
|
||||||
task_type: str = "code.change"
|
task_type: str = "code.change"
|
||||||
scope_type: str = "legacy"
|
|
||||||
tenant_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
project_id: str | None = None
|
|
||||||
conversation_id: str | None = None
|
|
||||||
run_id: str | None = None
|
|
||||||
issuer: dict[str, Any] = field(default_factory=dict)
|
issuer: dict[str, Any] = field(default_factory=dict)
|
||||||
intent: str | None = None
|
intent: str | None = None
|
||||||
required_capabilities: list[str] = field(default_factory=list)
|
required_capabilities: list[str] = field(default_factory=list)
|
||||||
@@ -325,17 +311,10 @@ class TaskPlan:
|
|||||||
raise ValueError("credential-secret requires metadata-only logging")
|
raise ValueError("credential-secret requires metadata-only logging")
|
||||||
if output_policy != "hash-count-status-only":
|
if output_policy != "hash-count-status-only":
|
||||||
raise ValueError("credential-secret requires restricted output")
|
raise ValueError("credential-secret requires restricted output")
|
||||||
scope = normalize_task_scope(data)
|
|
||||||
return cls(
|
return cls(
|
||||||
schema_version=schema_version,
|
schema_version=schema_version,
|
||||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
||||||
scope_type=scope.scope_type,
|
|
||||||
tenant_id=scope.tenant_id,
|
|
||||||
user_id=scope.user_id,
|
|
||||||
project_id=scope.project_id,
|
|
||||||
conversation_id=scope.conversation_id,
|
|
||||||
run_id=scope.run_id,
|
|
||||||
issuer=issuer,
|
issuer=issuer,
|
||||||
intent=data.get("intent"),
|
intent=data.get("intent"),
|
||||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
||||||
@@ -369,12 +348,6 @@ class TaskPlan:
|
|||||||
"schema_version": self.schema_version,
|
"schema_version": self.schema_version,
|
||||||
"task_id": self.task_id,
|
"task_id": self.task_id,
|
||||||
"task_type": self.task_type,
|
"task_type": self.task_type,
|
||||||
"scope_type": self.scope_type,
|
|
||||||
"tenant_id": self.tenant_id,
|
|
||||||
"user_id": self.user_id,
|
|
||||||
"project_id": self.project_id,
|
|
||||||
"conversation_id": self.conversation_id,
|
|
||||||
"run_id": self.run_id,
|
|
||||||
"issuer": self.issuer,
|
"issuer": self.issuer,
|
||||||
"intent": self.intent,
|
"intent": self.intent,
|
||||||
"required_capabilities": list(self.required_capabilities),
|
"required_capabilities": list(self.required_capabilities),
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Callable, Mapping
|
from typing import Callable
|
||||||
|
|
||||||
from .errors import ForbiddenError, LockedError
|
from .errors import ForbiddenError, LockedError
|
||||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
||||||
from .scope import TaskActorContext, actor_context as build_actor_context
|
|
||||||
|
|
||||||
|
|
||||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||||
@@ -94,10 +93,6 @@ class PolicyEngine:
|
|||||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||||
identity_capabilities: dict[str, set[str]] | None = None,
|
identity_capabilities: dict[str, set[str]] | None = None,
|
||||||
identity_actor_contexts: Mapping[
|
|
||||||
str, TaskActorContext | Mapping[str, Any]
|
|
||||||
]
|
|
||||||
| None = None,
|
|
||||||
admin_identities: set[str] | None = None,
|
admin_identities: set[str] | None = None,
|
||||||
readonly_worker_execution_enabled: bool = False,
|
readonly_worker_execution_enabled: bool = False,
|
||||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
||||||
@@ -114,26 +109,12 @@ class PolicyEngine:
|
|||||||
identity: set(capabilities)
|
identity: set(capabilities)
|
||||||
for identity, capabilities in (identity_capabilities or {}).items()
|
for identity, capabilities in (identity_capabilities or {}).items()
|
||||||
}
|
}
|
||||||
self.identity_actor_contexts = dict(identity_actor_contexts or {})
|
|
||||||
self.admin_identities = set(admin_identities or ())
|
self.admin_identities = set(admin_identities or ())
|
||||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
||||||
self.readonly_worker_profiles = {
|
self.readonly_worker_profiles = {
|
||||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
||||||
}
|
}
|
||||||
|
|
||||||
def actor_context(self, identity: str) -> TaskActorContext:
|
|
||||||
configured = self.identity_actor_contexts.get(
|
|
||||||
identity, self.identity_actor_contexts.get("*")
|
|
||||||
)
|
|
||||||
capabilities = set(
|
|
||||||
self.identity_capabilities.get(
|
|
||||||
identity, self.identity_capabilities.get("*", set())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if identity in self.admin_identities:
|
|
||||||
capabilities.add("task.admin")
|
|
||||||
return build_actor_context(identity, configured, capabilities)
|
|
||||||
|
|
||||||
def _readonly_worker_allows(
|
def _readonly_worker_allows(
|
||||||
self, action: str, actor: str, task: TaskRecord | None
|
self, action: str, actor: str, task: TaskRecord | None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
"""Trusted TaskPlane scope model and SQL authorization helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from .errors import ForbiddenError, ValidationError
|
|
||||||
|
|
||||||
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
|
||||||
PRIVILEGED_CAPABILITIES = frozenset(
|
|
||||||
{"*", "admin", "system.admin", "task.admin", "task.read.all"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _value(value: Any, field: str) -> Any:
|
|
||||||
if hasattr(value, "task_plan"):
|
|
||||||
value = value.task_plan
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return value.get(field)
|
|
||||||
return getattr(value, field, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _text(value: Any) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
result = str(value).strip()
|
|
||||||
return result or None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskScope:
|
|
||||||
scope_type: str
|
|
||||||
tenant_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
project_id: str | None = None
|
|
||||||
conversation_id: str | None = None
|
|
||||||
run_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TaskActorContext:
|
|
||||||
identity: str
|
|
||||||
scope_type: str = "legacy"
|
|
||||||
tenant_id: str | None = None
|
|
||||||
user_id: str | None = None
|
|
||||||
project_id: str | None = None
|
|
||||||
conversation_id: str | None = None
|
|
||||||
run_id: str | None = None
|
|
||||||
capabilities: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_task_scope(value: Any) -> TaskScope:
|
|
||||||
fields = {field: _text(_value(value, field)) for field in SCOPE_FIELDS}
|
|
||||||
declared = _text(_value(value, "scope_type"))
|
|
||||||
if declared is None:
|
|
||||||
declared = "tenant" if fields["tenant_id"] else "legacy"
|
|
||||||
if declared == "tenant":
|
|
||||||
if fields["tenant_id"] is None:
|
|
||||||
raise ValidationError(
|
|
||||||
"tenant scope requires tenant_id", code="TASK_SCOPE_TENANT_ID_REQUIRED"
|
|
||||||
)
|
|
||||||
elif declared == "legacy":
|
|
||||||
if any(fields.values()):
|
|
||||||
raise ValidationError(
|
|
||||||
"legacy scope cannot contain scoped ownership",
|
|
||||||
code="TASK_SCOPE_LEGACY_WITH_SCOPED_FIELDS",
|
|
||||||
)
|
|
||||||
elif declared == "system":
|
|
||||||
if any(fields[field] for field in ("tenant_id", "user_id", "project_id", "conversation_id")):
|
|
||||||
raise ValidationError(
|
|
||||||
"system scope cannot contain tenant ownership",
|
|
||||||
code="TASK_SCOPE_SYSTEM_WITH_TENANT_FIELDS",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValidationError("invalid task scope type", code="TASK_SCOPE_TYPE_INVALID")
|
|
||||||
return TaskScope(declared, **fields)
|
|
||||||
|
|
||||||
|
|
||||||
def actor_is_privileged(actor: TaskActorContext) -> bool:
|
|
||||||
return bool(actor.capabilities & PRIVILEGED_CAPABILITIES)
|
|
||||||
|
|
||||||
|
|
||||||
def actor_context(
|
|
||||||
identity: str,
|
|
||||||
configured: TaskActorContext | Mapping[str, Any] | None,
|
|
||||||
capabilities: set[str] | frozenset[str],
|
|
||||||
) -> TaskActorContext:
|
|
||||||
if isinstance(configured, TaskActorContext):
|
|
||||||
context = configured
|
|
||||||
else:
|
|
||||||
data = dict(configured or {})
|
|
||||||
data.pop("identity", None)
|
|
||||||
data.pop("capabilities", None)
|
|
||||||
context = TaskActorContext(identity=str(identity), **data)
|
|
||||||
scope = normalize_task_scope(context)
|
|
||||||
return replace(
|
|
||||||
context,
|
|
||||||
identity=str(identity),
|
|
||||||
scope_type=scope.scope_type,
|
|
||||||
tenant_id=scope.tenant_id,
|
|
||||||
user_id=scope.user_id,
|
|
||||||
project_id=scope.project_id,
|
|
||||||
conversation_id=scope.conversation_id,
|
|
||||||
run_id=scope.run_id,
|
|
||||||
capabilities=frozenset(str(item) for item in capabilities),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def bind_task_scope(plan: Any, actor: TaskActorContext, *, public_create: bool) -> Any:
|
|
||||||
requested = normalize_task_scope(plan)
|
|
||||||
if actor_is_privileged(actor):
|
|
||||||
trusted = normalize_task_scope(actor)
|
|
||||||
if requested.scope_type == "legacy" and trusted.scope_type != "legacy":
|
|
||||||
plan.scope_type = trusted.scope_type
|
|
||||||
for field in SCOPE_FIELDS:
|
|
||||||
setattr(plan, field, getattr(actor, field))
|
|
||||||
return plan
|
|
||||||
trusted = normalize_task_scope(actor)
|
|
||||||
if public_create and trusted.scope_type == "legacy":
|
|
||||||
raise ForbiddenError(
|
|
||||||
"authenticated identity has no tenant scope",
|
|
||||||
code="TASK_ACTOR_SCOPE_UNAVAILABLE",
|
|
||||||
)
|
|
||||||
if trusted.scope_type != "tenant":
|
|
||||||
if requested.scope_type == "legacy":
|
|
||||||
plan.scope_type = trusted.scope_type
|
|
||||||
for field in SCOPE_FIELDS:
|
|
||||||
setattr(plan, field, getattr(actor, field))
|
|
||||||
return plan
|
|
||||||
if requested.scope_type != trusted.scope_type:
|
|
||||||
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
|
||||||
return plan
|
|
||||||
if requested.scope_type not in {"legacy", "tenant"}:
|
|
||||||
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
|
||||||
updates: dict[str, Any] = {"scope_type": "tenant"}
|
|
||||||
for field in SCOPE_FIELDS:
|
|
||||||
declared = getattr(requested, field)
|
|
||||||
authoritative = getattr(actor, field)
|
|
||||||
if declared is not None and declared != authoritative:
|
|
||||||
raise ForbiddenError(
|
|
||||||
"task scope mismatch",
|
|
||||||
code="TASK_SCOPE_MISMATCH_DENY",
|
|
||||||
details={"field": field},
|
|
||||||
)
|
|
||||||
updates[field] = authoritative
|
|
||||||
for field, item in updates.items():
|
|
||||||
setattr(plan, field, item)
|
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def actor_scope_sql(actor: TaskActorContext | None) -> tuple[str, tuple[Any, ...]]:
|
|
||||||
if actor is None or actor_is_privileged(actor):
|
|
||||||
return "", ()
|
|
||||||
scope = normalize_task_scope(actor)
|
|
||||||
if scope.scope_type == "legacy":
|
|
||||||
return "scope_type = 'legacy'", ()
|
|
||||||
if scope.scope_type == "system":
|
|
||||||
return "scope_type = 'system'", ()
|
|
||||||
clauses = ["scope_type = 'tenant'", "tenant_id = ?"]
|
|
||||||
params: list[Any] = [scope.tenant_id]
|
|
||||||
for field in ("user_id", "project_id", "conversation_id", "run_id"):
|
|
||||||
value = getattr(scope, field)
|
|
||||||
if value is None:
|
|
||||||
clauses.append(f"{field} IS NULL")
|
|
||||||
else:
|
|
||||||
clauses.append(f"({field} IS NULL OR {field} = ?)")
|
|
||||||
params.append(value)
|
|
||||||
return " AND ".join(clauses), tuple(params)
|
|
||||||
|
|
||||||
|
|
||||||
def task_scope_values(value: Any) -> tuple[str | None, ...]:
|
|
||||||
scope = normalize_task_scope(value)
|
|
||||||
return (
|
|
||||||
scope.scope_type,
|
|
||||||
scope.tenant_id,
|
|
||||||
scope.user_id,
|
|
||||||
scope.project_id,
|
|
||||||
scope.conversation_id,
|
|
||||||
scope.run_id,
|
|
||||||
)
|
|
||||||
@@ -32,7 +32,6 @@ from .models import (
|
|||||||
redact_value,
|
redact_value,
|
||||||
validate_identifier,
|
validate_identifier,
|
||||||
)
|
)
|
||||||
from .scope import TaskActorContext, actor_scope_sql, task_scope_values
|
|
||||||
|
|
||||||
|
|
||||||
EventEnvelope = tuple[str, str, str, str, str, float]
|
EventEnvelope = tuple[str, str, str, str, str, float]
|
||||||
@@ -135,12 +134,6 @@ class TaskPlaneStore:
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
task_json TEXT NOT NULL,
|
task_json TEXT NOT NULL,
|
||||||
task_type TEXT NOT NULL,
|
task_type TEXT NOT NULL,
|
||||||
scope_type TEXT NOT NULL DEFAULT 'legacy',
|
|
||||||
tenant_id TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
project_id TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
run_id TEXT,
|
|
||||||
root_task_id TEXT NOT NULL,
|
root_task_id TEXT NOT NULL,
|
||||||
parent_task_id TEXT,
|
parent_task_id TEXT,
|
||||||
correlation_id TEXT,
|
correlation_id TEXT,
|
||||||
@@ -279,21 +272,6 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self._ensure_columns(
|
|
||||||
"tasks",
|
|
||||||
{
|
|
||||||
"scope_type": "TEXT NOT NULL DEFAULT 'legacy'",
|
|
||||||
"tenant_id": "TEXT",
|
|
||||||
"user_id": "TEXT",
|
|
||||||
"project_id": "TEXT",
|
|
||||||
"conversation_id": "TEXT",
|
|
||||||
"run_id": "TEXT",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"UPDATE tasks SET scope_type='legacy' "
|
|
||||||
"WHERE scope_type IS NULL OR TRIM(scope_type)=''"
|
|
||||||
)
|
|
||||||
self._ensure_columns(
|
self._ensure_columns(
|
||||||
"workers",
|
"workers",
|
||||||
{
|
{
|
||||||
@@ -321,14 +299,6 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_tenant_project "
|
|
||||||
"ON tasks(scope_type, tenant_id, project_id)"
|
|
||||||
)
|
|
||||||
self.conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_owner "
|
|
||||||
"ON tasks(scope_type, tenant_id, user_id, conversation_id, run_id)"
|
|
||||||
)
|
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
||||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -377,42 +347,6 @@ class TaskPlaneStore:
|
|||||||
raise NotFoundError("task")
|
raise NotFoundError("task")
|
||||||
task_json = json.loads(row["task_json"])
|
task_json = json.loads(row["task_json"])
|
||||||
task_plan = TaskPlan.from_dict(task_json)
|
task_plan = TaskPlan.from_dict(task_json)
|
||||||
stored_scope = tuple(
|
|
||||||
row[field]
|
|
||||||
for field in (
|
|
||||||
"scope_type",
|
|
||||||
"tenant_id",
|
|
||||||
"user_id",
|
|
||||||
"project_id",
|
|
||||||
"conversation_id",
|
|
||||||
"run_id",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
json_has_scope = any(
|
|
||||||
field in task_json
|
|
||||||
for field in (
|
|
||||||
"scope_type",
|
|
||||||
"tenant_id",
|
|
||||||
"user_id",
|
|
||||||
"project_id",
|
|
||||||
"conversation_id",
|
|
||||||
"run_id",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if json_has_scope and stored_scope != task_scope_values(task_plan):
|
|
||||||
raise ConflictError(
|
|
||||||
"task scope persistence mismatch",
|
|
||||||
code="TASK_SCOPE_PERSISTENCE_MISMATCH",
|
|
||||||
)
|
|
||||||
if not json_has_scope:
|
|
||||||
(
|
|
||||||
task_plan.scope_type,
|
|
||||||
task_plan.tenant_id,
|
|
||||||
task_plan.user_id,
|
|
||||||
task_plan.project_id,
|
|
||||||
task_plan.conversation_id,
|
|
||||||
task_plan.run_id,
|
|
||||||
) = stored_scope
|
|
||||||
task_plan.parent_task_id = row["parent_task_id"]
|
task_plan.parent_task_id = row["parent_task_id"]
|
||||||
task_plan.correlation_id = row["correlation_id"]
|
task_plan.correlation_id = row["correlation_id"]
|
||||||
return TaskRecord(
|
return TaskRecord(
|
||||||
@@ -455,7 +389,6 @@ class TaskPlaneStore:
|
|||||||
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
||||||
task_plan.task_id = task_id
|
task_plan.task_id = task_id
|
||||||
task_json = redact_value(task_plan.to_dict(redact=False))
|
task_json = redact_value(task_plan.to_dict(redact=False))
|
||||||
scope_values = task_scope_values(task_plan)
|
|
||||||
now = _now()
|
now = _now()
|
||||||
with self._transaction():
|
with self._transaction():
|
||||||
if idempotency_key:
|
if idempotency_key:
|
||||||
@@ -464,11 +397,7 @@ class TaskPlaneStore:
|
|||||||
(idempotency_key,),
|
(idempotency_key,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
if existing:
|
||||||
existing_plan = TaskPlan.from_dict(
|
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
||||||
json.loads(existing["task_json"])
|
|
||||||
)
|
|
||||||
existing_json = redact_value(existing_plan.to_dict(redact=False))
|
|
||||||
if _canonical(existing_json) != _canonical(task_json):
|
|
||||||
raise ConflictError("idempotency key payload mismatch")
|
raise ConflictError("idempotency key payload mismatch")
|
||||||
return self.get_task(existing["task_id"])
|
return self.get_task(existing["task_id"])
|
||||||
root_task_id = task_id
|
root_task_id = task_id
|
||||||
@@ -483,18 +412,14 @@ class TaskPlaneStore:
|
|||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO tasks
|
INSERT INTO tasks
|
||||||
(task_id, revision, status, task_json, task_type,
|
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
||||||
scope_type, tenant_id, user_id, project_id, conversation_id, run_id,
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||||
root_task_id, parent_task_id, correlation_id, idempotency_key,
|
|
||||||
created_at, updated_at, failure_reason)
|
|
||||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
task_id,
|
task_id,
|
||||||
"CREATED",
|
"CREATED",
|
||||||
json.dumps(task_json, ensure_ascii=False),
|
json.dumps(task_json, ensure_ascii=False),
|
||||||
task_plan.task_type,
|
task_plan.task_type,
|
||||||
*scope_values,
|
|
||||||
root_task_id,
|
root_task_id,
|
||||||
task_plan.parent_task_id,
|
task_plan.parent_task_id,
|
||||||
task_plan.correlation_id,
|
task_plan.correlation_id,
|
||||||
@@ -526,30 +451,21 @@ class TaskPlaneStore:
|
|||||||
)
|
)
|
||||||
return self.get_task(task.task_id)
|
return self.get_task(task.task_id)
|
||||||
|
|
||||||
def get_task(
|
def get_task(self, task_id: str) -> TaskRecord:
|
||||||
self, task_id: str, actor_context: TaskActorContext | None = None
|
|
||||||
) -> TaskRecord:
|
|
||||||
task_id = _validated_id(task_id, "task_id")
|
task_id = _validated_id(task_id, "task_id")
|
||||||
predicate, scope_params = actor_scope_sql(actor_context)
|
|
||||||
sql = "SELECT * FROM tasks WHERE task_id=?"
|
|
||||||
if predicate:
|
|
||||||
sql += f" AND ({predicate})"
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self.conn.execute(sql, (task_id, *scope_params)).fetchone()
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
||||||
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise NotFoundError(f"task {task_id} not found")
|
raise NotFoundError(f"task {task_id} not found")
|
||||||
return self._row_to_task(row)
|
return self._row_to_task(row)
|
||||||
|
|
||||||
def list_tasks(
|
def list_tasks(self) -> list[TaskRecord]:
|
||||||
self, actor_context: TaskActorContext | None = None
|
|
||||||
) -> list[TaskRecord]:
|
|
||||||
predicate, scope_params = actor_scope_sql(actor_context)
|
|
||||||
sql = "SELECT * FROM tasks"
|
|
||||||
if predicate:
|
|
||||||
sql += f" WHERE ({predicate})"
|
|
||||||
sql += " ORDER BY created_at DESC"
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rows = self.conn.execute(sql, scope_params).fetchall()
|
rows = self.conn.execute(
|
||||||
|
"SELECT * FROM tasks ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
return [self._row_to_task(row) for row in rows]
|
return [self._row_to_task(row) for row in rows]
|
||||||
|
|
||||||
def get_task_budget(self, task_id: str) -> Budget:
|
def get_task_budget(self, task_id: str) -> Budget:
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
-- R4.2 P0-B durable Message/Handoff Fabric.
|
|
||||||
-- Forward-only. The existing 20260707 message_outbox remains a compatibility source.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS message_handoff (
|
|
||||||
envelope_id TEXT PRIMARY KEY,
|
|
||||||
stream_key TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
message_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL,
|
|
||||||
payload_sha256 TEXT NOT NULL,
|
|
||||||
tenant_id TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
project_id TEXT,
|
|
||||||
conversation_id TEXT,
|
|
||||||
task_id TEXT,
|
|
||||||
run_id TEXT,
|
|
||||||
causation_id TEXT,
|
|
||||||
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
|
||||||
capability_scope_json TEXT NOT NULL,
|
|
||||||
confidentiality_scope TEXT NOT NULL,
|
|
||||||
idempotency_key TEXT NOT NULL,
|
|
||||||
expires_at TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
||||||
delivered_at TEXT,
|
|
||||||
acked_at TEXT,
|
|
||||||
UNIQUE(stream_key, idempotency_key),
|
|
||||||
UNIQUE(stream_key, sequence)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
|
||||||
ON message_handoff(target, status, created_at);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
|
||||||
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AIOS owner-controlled Task retry-extension API
|
|
||||||
After=aios-agent-task-api.service
|
|
||||||
Wants=aios-agent-task-api.service
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=aios-agent-task
|
|
||||||
Group=aios-agent-task
|
|
||||||
LoadCredential=agent_task_control_token:/etc/aios-gitops/agent-task-control.token
|
|
||||||
EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env
|
|
||||||
Environment=AIOS_AGENT_TASK_DB=/var/lib/aios-agent-task/state.sqlite3
|
|
||||||
Environment=AIOS_AGENT_TASK_CONTROL_TOKEN_FILE=%d/agent_task_control_token
|
|
||||||
Environment=AIOS_TASK_RETRY_HOST=127.0.0.1
|
|
||||||
Environment=AIOS_TASK_RETRY_PORT=18941
|
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_agent_task_retry_extension.py
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
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
|
|
||||||
ReadWritePaths=/var/lib/aios-agent-task
|
|
||||||
UMask=0077
|
|
||||||
|
|
||||||
[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
|
|
||||||
@@ -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
|
|
||||||
+14
-13
@@ -1,29 +1,30 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=AIOS governed Online Agent Worker
|
Description=AIOS generic governed Online Agent Worker (2026-08-15)
|
||||||
After=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
After=aios-agent-task-api.service aios-online-agent-worker-register-20260815.service
|
||||||
Wants=aios-agent-task-api.service aios-chatgpt-bridge-v2-staging.service
|
Wants=aios-online-agent-worker-register-20260815.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=aios-engineering-worker
|
User=aios-engineering-worker
|
||||||
Group=aios-engineering-worker
|
Group=aios-engineering-worker
|
||||||
SupplementaryGroups=gitops aios-executor-exchange
|
SupplementaryGroups=gitops
|
||||||
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
LoadCredentialEncrypted=agent_token:/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
Environment=PYTHONUNBUFFERED=1
|
Environment=PYTHONUNBUFFERED=1
|
||||||
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
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_GOVERNED_WRITE_API_URL=http://127.0.0.1:18892
|
||||||
Environment=AIOS_AGENT_ID=online-engineering-worker
|
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-generic-20260815
|
||||||
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
Environment=AIOS_AGENT_VERSION=2026-08-17.1-dynamic
|
Environment=AIOS_AGENT_VERSION=2026-08-15.3-generic
|
||||||
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_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-20260815
|
||||||
Environment=AIOS_WORKER_STATE_ROOT=/var/lib/aios-online-agent-worker
|
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker-20260815/workspaces
|
||||||
Environment=AIOS_WORKER_WORKSPACE_ROOT=/var/lib/aios-online-agent-worker/workspaces
|
Environment=AIOS_WORKER_POLL_SECONDS=5
|
||||||
ExecStart=/usr/bin/python3 -B /opt/aios/current/active-runtime/app/aios_online_agent_worker.py
|
Environment=AIOS_WORKER_HEARTBEAT_SECONDS=10
|
||||||
|
Environment=AIOS_WORKER_LEASE_SECONDS=300
|
||||||
|
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-online-agent-worker-20260815/online_agent_worker.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
TimeoutStopSec=20
|
TimeoutStopSec=15
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
@@ -38,7 +39,7 @@ RestrictNamespaces=true
|
|||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
ReadOnlyPaths=/data/aios-gitops/repo.git
|
ReadOnlyPaths=/data/aios-gitops/repo.git
|
||||||
ReadWritePaths=/var/lib/aios-online-agent-worker
|
ReadWritePaths=/var/lib/aios-online-agent-worker-20260815
|
||||||
UMask=0027
|
UMask=0027
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=AIOS generic Online Agent register-or-recover (2026-08-15)
|
||||||
|
After=network-online.target aios-gitops-model-api.service aios-agent-task-api.service
|
||||||
|
Wants=network-online.target
|
||||||
|
Before=aios-online-agent-worker-20260815.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
RemainAfterExit=yes
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
LoadCredential=bridge_control_token:/run/credentials/aios-gitops-model-api.service/agent_task_control_token
|
||||||
|
Environment=AIOS_AGENT_TASK_API_URL=http://127.0.0.1:18940
|
||||||
|
Environment=AIOS_AGENT_ID=online-engineering-worker-cn-network-mkt-generic-20260815
|
||||||
|
Environment=AIOS_AGENT_TYPE=trusted-online-engineering-worker
|
||||||
|
Environment=AIOS_AGENT_VERSION=2026-08-15.3-generic
|
||||||
|
Environment=AIOS_WORKER_ENCRYPTED_CREDENTIAL=/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token
|
||||||
|
Environment=AIOS_WORKER_REGISTRATION_STATE=/var/lib/aios-online-agent-worker-20260815/registration
|
||||||
|
ExecStart=/usr/bin/python3 -B /usr/local/lib/aios-online-agent-worker-20260815/register.py
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
ReadWritePaths=/etc/credstore.encrypted /var/lib/aios-online-agent-worker-20260815
|
||||||
|
UMask=0077
|
||||||
|
TimeoutStartSec=120
|
||||||
|
|
||||||
|
[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
|
|
||||||
@@ -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 -
|
|
||||||
@@ -240,114 +240,6 @@ components:
|
|||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
properties:
|
properties:
|
||||||
candidate_id: {type: string}
|
candidate_id: {type: string}
|
||||||
BridgeWriterLeaseRecoveryRequest:
|
|
||||||
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
|
|
||||||
enum: [aios.task-writer-lease.v2, aios.task-writer-lease-request.v2]
|
|
||||||
task_id: {type: string, minLength: 1, maxLength: 256}
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
repository: {type: string, const: aios/aios}
|
|
||||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
|
||||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
|
||||||
BridgeWriterLeaseResponse:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, create_target_branch, worktree_mode, cas_ref_update_required]
|
|
||||||
properties:
|
|
||||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
|
||||||
task_id: {type: string}
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
repository: {type: string, const: aios/aios}
|
|
||||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
|
||||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
|
||||||
create_target_branch: {type: boolean}
|
|
||||||
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
|
||||||
cas_ref_update_required: {type: boolean, const: true}
|
|
||||||
lease_expires_at: {type: string, format: date-time}
|
|
||||||
BridgeWriterLeaseRecoveryResponse:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, lease_expires_at, create_target_branch, worktree_mode, cas_ref_update_required, recovered, bootstrapped, idempotent, runtime_rehydrated, durable_branch_lease_present]
|
|
||||||
properties:
|
|
||||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
|
||||||
task_id: {type: string}
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
repository: {type: string, const: aios/aios}
|
|
||||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
|
||||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
|
||||||
lease_expires_at: {type: string, format: date-time}
|
|
||||||
create_target_branch: {type: boolean, const: false}
|
|
||||||
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
|
||||||
cas_ref_update_required: {type: boolean, const: true}
|
|
||||||
recovered: {type: boolean}
|
|
||||||
bootstrapped: {type: boolean}
|
|
||||||
idempotent: {type: boolean}
|
|
||||||
runtime_rehydrated: {type: boolean}
|
|
||||||
durable_branch_lease_present: {type: boolean}
|
|
||||||
BridgeCommitFileRequest:
|
|
||||||
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, minLength: 1}
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
repository: {type: string, const: aios/aios}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
|
||||||
path: {type: string, minLength: 1, maxLength: 512}
|
|
||||||
content_base64: {type: string, format: byte}
|
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
BridgeCommitFilesRequest:
|
|
||||||
type: object
|
|
||||||
additionalProperties: false
|
|
||||||
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message]
|
|
||||||
properties:
|
|
||||||
task_id: {type: string, minLength: 1}
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
repository: {type: string, const: aios/aios}
|
|
||||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
|
||||||
files: {type: object, additionalProperties: {type: string, format: byte}, minProperties: 1}
|
|
||||||
message: {type: string, minLength: 1, maxLength: 200}
|
|
||||||
BridgeCommitResponse:
|
|
||||||
type: object
|
|
||||||
additionalProperties: true
|
|
||||||
required: [job_id, commit_oid, commit_visible, branch_mode, target_branch, generation]
|
|
||||||
properties:
|
|
||||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
|
||||||
commit_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
|
||||||
commit_visible: {type: boolean}
|
|
||||||
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
|
||||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
|
||||||
generation: {type: integer, minimum: 1}
|
|
||||||
BridgeGovernedCapabilities:
|
|
||||||
type: object
|
|
||||||
required: [bridge_schema_version, governed_writes_enabled, operations, operation_contracts]
|
|
||||||
properties:
|
|
||||||
bridge_schema_version: {type: string, const: aios.chatgpt-gitops-bridge.v2}
|
|
||||||
governed_writes_enabled: {type: boolean, const: true}
|
|
||||||
operations: {type: array, items: {type: string}}
|
|
||||||
operation_contracts: {type: object, additionalProperties: true}
|
|
||||||
paths:
|
paths:
|
||||||
/createReleaseIntent:
|
/createReleaseIntent:
|
||||||
post:
|
post:
|
||||||
@@ -675,118 +567,3 @@ paths:
|
|||||||
'200': {description: Safe GitOps plan}
|
'200': {description: Safe GitOps plan}
|
||||||
'401': {description: Unauthorized}
|
'401': {description: Unauthorized}
|
||||||
x-openai-isConsequential: true
|
x-openai-isConsequential: true
|
||||||
/v2/capabilities:
|
|
||||||
get:
|
|
||||||
operationId: getGovernedWriteCapabilities
|
|
||||||
summary: Read the governed Bridge v2 capability contract
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Governed operations and their request/response contracts
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeGovernedCapabilities'}
|
|
||||||
/v2/writer-lease:
|
|
||||||
post:
|
|
||||||
operationId: acquireWriterLease
|
|
||||||
summary: Acquire the exact governed writer lease for a task generation
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Exact detached worktree lease binding
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseResponse'}
|
|
||||||
'400': {description: Invalid lease binding}
|
|
||||||
'409': {description: Branch, base, or generation conflict}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/v2/writer-lease/bootstrap:
|
|
||||||
post:
|
|
||||||
operationId: bootstrapWriterLease
|
|
||||||
summary: Bootstrap a governed writer runtime without creating a durable lease
|
|
||||||
description: >
|
|
||||||
Staging-only runtime recovery. It validates the exact task, repository,
|
|
||||||
base OID, expected branch OID, target branch, and generation. It must
|
|
||||||
not create a branch, insert a branch lease, or consume a generation.
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Runtime binding bootstrapped or returned idempotently
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
|
||||||
'400': {description: Invalid lease binding}
|
|
||||||
'409': {description: A matching durable lease already exists or binding conflicts}
|
|
||||||
'503': {description: Runtime or durable state unavailable}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/v2/writer-lease/renew:
|
|
||||||
post:
|
|
||||||
operationId: renewWriterLease
|
|
||||||
summary: Recover an expired governed writer lease without changing its binding
|
|
||||||
description: >
|
|
||||||
Renews only the current task generation and exact repository, base OID,
|
|
||||||
expected branch OID, and target branch. Durable renewal is revision-CAS
|
|
||||||
protected; runtime rehydration is reported separately.
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Lease recovered or idempotently observed as already active
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
|
||||||
'400': {description: Invalid schema, generation, or lease duration}
|
|
||||||
'409': {description: Task, base, repository, branch, generation, or durable lease conflict}
|
|
||||||
'503': {description: Durable job store or runtime unavailable}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/v2/commit-file:
|
|
||||||
post:
|
|
||||||
operationId: commitFileGoverned
|
|
||||||
summary: Commit one file through the governed writer lease
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeCommitFileRequest'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Authority and remote visibility receipt
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
|
||||||
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
|
||||||
'503': {description: Reconciliation required}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
/v2/commit-files:
|
|
||||||
post:
|
|
||||||
operationId: commitFilesGoverned
|
|
||||||
summary: Atomically commit a bounded set of files through the governed writer lease
|
|
||||||
security: [{bearerAuth: []}]
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeCommitFilesRequest'}
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Authority and remote visibility receipt
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
|
||||||
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
|
||||||
'503': {description: Reconciliation required}
|
|
||||||
x-openai-isConsequential: true
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
# R4.2 P0-A TaskPlane Scope Isolation Design
|
|
||||||
|
|
||||||
Status: approved candidate design. This document is not evidence of a protected-main merge or production deployment.
|
|
||||||
|
|
||||||
## Boundary
|
|
||||||
|
|
||||||
- Task: `r42-p0a-taskplane-scope-isolation-20260816`
|
|
||||||
- Governed Job: `job-3fd743b51971c6007923699c1233f84c`
|
|
||||||
- Repository: `aios/aios`
|
|
||||||
- Base: `0eb6e1a864edfdaeef37b438991707a110e21558`
|
|
||||||
- Target branch: `agent/r42-p0a-taskplane-scope-isolation-20260816`
|
|
||||||
- Generation: `1`
|
|
||||||
|
|
||||||
The implementation reuses this exact boundary. It does not create a replacement Task, Job, branch, generation, or idempotency key.
|
|
||||||
|
|
||||||
## Model and invariants
|
|
||||||
|
|
||||||
`TaskPlan` gains first-class `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` fields. The fields participate in accepted-key validation, parsing, serialization, persistence, and authorization.
|
|
||||||
|
|
||||||
Historical rows without scope data normalize to `scope_type=legacy`; no tenant is inferred. Tenant-scoped tasks require a non-empty `tenant_id`. System/global resources use an explicit `scope_type=system` invariant and do not receive a fabricated tenant. A task may add user, project, conversation, and run ownership within its tenant.
|
|
||||||
|
|
||||||
## Trusted ActorContext
|
|
||||||
|
|
||||||
TaskPlane defines a small immutable `TaskActorContext` used only for authorization. The HTTP authenticator returns an identity. `PolicyEngine` resolves that identity through server-owned policy configuration into the effective context and capabilities. Request fields such as `tenant_id`, `user_id`, `group_id`, and `role` are declarations only and never expand authority.
|
|
||||||
|
|
||||||
Public create, list, and get paths always use the resolved context. Internal compatibility calls may omit a context and retain explicit legacy behavior, but the HTTP adapter never uses that path.
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
SQLite remains the current runtime. `_create_schema()` performs an idempotent forward migration that adds the six scope columns and indexes, then backfills only missing `scope_type` values to `legacy`.
|
|
||||||
|
|
||||||
New writes persist scope in both the canonical `task_json` representation and first-class columns. Reads accept historical JSON without scope, using legacy columns, but reject a new-row mismatch between JSON and columns. SQL list/get predicates are parameterized and applied before row hydration.
|
|
||||||
|
|
||||||
PostgreSQL remains a separate migration target and is not claimed by this change.
|
|
||||||
|
|
||||||
## Authorization flow
|
|
||||||
|
|
||||||
1. HTTP bearer authentication yields an identity.
|
|
||||||
2. `PolicyEngine` verifies capability and resolves the identity to `TaskActorContext`.
|
|
||||||
3. Create binds the requested scope to the trusted context and rejects mismatches.
|
|
||||||
4. List/get pass the trusted context to `TaskPlaneStore`.
|
|
||||||
5. Store applies tenant and ownership predicates in SQL; privileged capabilities may explicitly cross scope.
|
|
||||||
|
|
||||||
Tenant actors cannot read legacy tasks, another tenant, or mismatched owned resources. Legacy actors can only read legacy tasks. System actors require explicit privileged capability for cross-scope access.
|
|
||||||
|
|
||||||
## Failure behavior
|
|
||||||
|
|
||||||
Missing tenant scope, forged scope, unknown scope types, JSON/column divergence, and unauthorized list/get operations fail closed with canonical TaskPlane errors. Public get does not reveal whether an out-of-scope task exists.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Tests exercise real model round trips, SQLite migration/restart, Service/Policy/Store wiring, tenant A versus tenant B list/get denial, ownership filtering, forged create payload rejection, legacy isolation, privileged access, and SQL parameterization. Existing TaskPlane API, RBAC, recovery/security, and security-contract suites remain mandatory.
|
|
||||||
|
|
||||||
The candidate is complete only after real `TASK_CLAIMED`, `TASK_HEARTBEAT`, exact WriterLease, governed commit, OID convergence, result submission, and `TASK_VERIFIED`. Protected main and production remain unchanged.
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# R4.2 P0-A TaskPlane Scope Isolation
|
|
||||||
|
|
||||||
Status: candidate implementation. This is not proof of protected-main promotion or production deployment.
|
|
||||||
|
|
||||||
Task scope is now represented by `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` in the model and SQLite persistence. Historical rows become `legacy`; their tenant is never guessed. Tenant tasks require `tenant_id`. System resources use `scope_type=system` without a fabricated tenant.
|
|
||||||
|
|
||||||
The HTTP adapter uses the authenticated identity. `PolicyEngine` resolves that identity through server-owned `identity_actor_contexts`; request payload fields do not grant scope. Public create binds the trusted scope, and public list/get apply parameterized scope predicates in `TaskPlaneStore`.
|
|
||||||
|
|
||||||
SQLite is the current runtime database. PostgreSQL remains a separate migration and runtime-acceptance gate.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# R4.2 P0-B — Durable Message / Handoff Fabric
|
|
||||||
|
|
||||||
Status: candidate implementation; not evidence of protected-main merge or Production deployment.
|
|
||||||
|
|
||||||
`MessageEnvelope` is the single durable handoff identity and carries source/target/type, tenant/user/
|
|
||||||
project/conversation/task/run scope, causation, sequence, capability scope, confidentiality scope,
|
|
||||||
idempotency key, expiry and delivery state.
|
|
||||||
|
|
||||||
Identity includes **target + scope + sequence + idempotency key + payload hash**, fixing the existing
|
|
||||||
AgentEventBus target collision. Server ActorContext is authoritative; caller scope may narrow but may
|
|
||||||
not widen/forge trusted tenant/user/project/conversation/run fields.
|
|
||||||
|
|
||||||
`DurableHandoffStore` provides SQLite durability in the current runtime. Each logical stream has
|
|
||||||
unique `(stream_key, idempotency_key)` and `(stream_key, sequence)`. Exact retry returns `REPLAY`;
|
|
||||||
changed payload under the same key is rejected; sequence gaps are rejected; expired messages are not
|
|
||||||
delivered.
|
|
||||||
|
|
||||||
Confidentiality scopes:
|
|
||||||
|
|
||||||
- public: no tenant requirement
|
|
||||||
- tenant: same tenant
|
|
||||||
- project: same tenant + project
|
|
||||||
- private: same tenant + project + user
|
|
||||||
- run: same tenant + project + user + run
|
|
||||||
|
|
||||||
Required `capability_scope` must be present on the authenticated delivery actor unless the actor is
|
|
||||||
explicitly administrative.
|
|
||||||
|
|
||||||
Compatibility adapters convert AgentEventBus events, TaskPlane CloudEvents and legacy Agent OS
|
|
||||||
MessageBus messages into the durable envelope. Existing message_outbox remains intact as a migration
|
|
||||||
source; P0-B does not delete or silently rewrite it.
|
|
||||||
|
|
||||||
Required gates:
|
|
||||||
`MESSAGE_SCOPE_MISMATCH_DENY`, `MESSAGE_TARGET_COLLISION_DENY`, `MESSAGE_IDEMPOTENCY_REPLAY`,
|
|
||||||
`MESSAGE_SEQUENCE_GAP_DETECT`, `MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE`,
|
|
||||||
`HANDOFF_CROSS_TENANT_DENY`, plus existing P0-A TaskPlane scope/security regressions.
|
|
||||||
|
|
||||||
SQLite remains current runtime persistence. PostgreSQL remains a separate migration gate.
|
|
||||||
@@ -48,8 +48,3 @@ class MessageBus:
|
|||||||
messages = list(self.queue)
|
messages = list(self.queue)
|
||||||
self.queue.clear()
|
self.queue.clear()
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
# R4.2 P0-B compatibility adapter.
|
|
||||||
def to_handoff_envelope(message, *, target=None, sequence=1, idempotency_key=None, **scope):
|
|
||||||
from message_handoff_adapters import legacy_message_to_envelope
|
|
||||||
return legacy_message_to_envelope(message, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Generic Governed Online Agent Worker
|
||||||
|
|
||||||
|
This worker is a pull-based, restartable consumer for `aios.agent-task.v1`.
|
||||||
|
The service configuration contains only stable runtime identity, capability
|
||||||
|
names, API endpoints, heartbeat timing, and the credential projection. It does
|
||||||
|
not contain a task id, repository base OID, target branch, or plan-file path.
|
||||||
|
|
||||||
|
## Dynamic task contract
|
||||||
|
|
||||||
|
Engineering tasks carry `metadata.execution` with schema
|
||||||
|
`aios.online-agent.execution.v2`. The payload supplies the repository, exact
|
||||||
|
base OID, target branch, detached-worktree policy, adapter name, explicit file
|
||||||
|
change set, verification commands, and governed commit idempotency keys.
|
||||||
|
Task prose is descriptive only and is never passed to a shell.
|
||||||
|
|
||||||
|
The worker accepts only structured operations:
|
||||||
|
|
||||||
|
- `deterministic`: allow-listed file changes and test commands;
|
||||||
|
- `codex`: fail-closed until a task supplies an explicit Codex file/command
|
||||||
|
policy;
|
||||||
|
- `hermes`: fail-closed because the current Hermes runtime is a user-chat
|
||||||
|
service, not an engineering executor.
|
||||||
|
|
||||||
|
All command execution uses `shell=False`, fixed program allowlists, relative
|
||||||
|
paths beneath an ephemeral detached worktree, and output redaction. Direct
|
||||||
|
`git branch`, `update-ref`, `push`, `commit`, `reset`, `clean`, main,
|
||||||
|
production, tag, and baseline mutations are rejected by the worker.
|
||||||
|
|
||||||
|
## Governed write path
|
||||||
|
|
||||||
|
The worker calls the Bridge in this order:
|
||||||
|
|
||||||
|
1. `POST /v2/governed-task`;
|
||||||
|
2. `POST /v2/writer-lease`;
|
||||||
|
3. `POST /v2/commit-files`;
|
||||||
|
4. read-only authority-ref visibility verification.
|
||||||
|
|
||||||
|
CREATE_NEW is represented by the task payload and is normalized by the Bridge
|
||||||
|
lease authority. The worker never creates an authoritative ref itself.
|
||||||
|
|
||||||
|
## Lifecycle and recovery
|
||||||
|
|
||||||
|
The runtime registers with `register_or_recover`, receives its agent token
|
||||||
|
once, and stores only the encrypted systemd credential. The consumer loops over
|
||||||
|
poll, atomically claims one task, sends the first heartbeat, emits
|
||||||
|
`TASK_STARTED`, and continues heartbeats while executing.
|
||||||
|
|
||||||
|
State is atomically stored per task without agent or lease token material. If
|
||||||
|
the process stops after a governed commit but before result submission, the
|
||||||
|
next lease attempt reuses the stable governed idempotency key and submits the
|
||||||
|
stored commit evidence without issuing a second commit.
|
||||||
|
|
||||||
|
The control plane must independently verify submitted results; worker-local
|
||||||
|
success is not production or mainline authorization.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Generic governed Online Agent Worker runtime."""
|
||||||
|
|
||||||
|
__all__ = ["worker"]
|
||||||
+51
-583
@@ -18,8 +18,6 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
|
||||||
import io
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -27,19 +25,13 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import tarfile
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from aios_online_agent_execution import (
|
|
||||||
ExecutionProjectionError,
|
|
||||||
validate_execution_envelope,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
API_BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
API_BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||||
BRIDGE_BASE = os.environ.get("AIOS_GOVERNED_WRITE_API_URL", "http://127.0.0.1:18892").rstrip("/")
|
BRIDGE_BASE = os.environ.get("AIOS_GOVERNED_WRITE_API_URL", "http://127.0.0.1:18892").rstrip("/")
|
||||||
@@ -63,32 +55,11 @@ GIT_CONFIG_GLOBAL = STATE_ROOT / "gitconfig"
|
|||||||
POLL_SECONDS = int(os.environ.get("AIOS_WORKER_POLL_SECONDS", "5"))
|
POLL_SECONDS = int(os.environ.get("AIOS_WORKER_POLL_SECONDS", "5"))
|
||||||
HEARTBEAT_SECONDS = int(os.environ.get("AIOS_WORKER_HEARTBEAT_SECONDS", "20"))
|
HEARTBEAT_SECONDS = int(os.environ.get("AIOS_WORKER_HEARTBEAT_SECONDS", "20"))
|
||||||
LEASE_SECONDS = int(os.environ.get("AIOS_WORKER_LEASE_SECONDS", "300"))
|
LEASE_SECONDS = int(os.environ.get("AIOS_WORKER_LEASE_SECONDS", "300"))
|
||||||
WORKER_CAPABILITIES = tuple(
|
|
||||||
sorted(
|
|
||||||
item.strip()
|
|
||||||
for item in os.environ.get(
|
|
||||||
"AIOS_WORKER_CAPABILITIES",
|
|
||||||
"git.read,git.test,governed.write.branch,governed.write.file",
|
|
||||||
).split(",")
|
|
||||||
if item.strip()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
MAX_TASK_BYTES = 512 * 1024
|
MAX_TASK_BYTES = 512 * 1024
|
||||||
MAX_FILE_BYTES = 2 * 1024 * 1024
|
MAX_FILE_BYTES = 2 * 1024 * 1024
|
||||||
ISOLATED_EXECUTOR_ROOT = Path(
|
|
||||||
os.environ.get("AIOS_ISOLATED_EXECUTOR_ROOT", "/var/lib/aios-online-agent-executor")
|
|
||||||
)
|
|
||||||
ISOLATED_EXECUTOR_TIMEOUT_SECONDS = int(
|
|
||||||
os.environ.get("AIOS_ISOLATED_EXECUTOR_TIMEOUT_SECONDS", "1800")
|
|
||||||
)
|
|
||||||
ISOLATED_EXECUTOR_TIMEOUT_HARD_MAX_SECONDS = 7200
|
|
||||||
GIT_VISIBILITY_TIMEOUT_SECONDS = int(
|
|
||||||
os.environ.get("AIOS_GIT_VISIBILITY_TIMEOUT_SECONDS", "45")
|
|
||||||
)
|
|
||||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||||
REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,254}$")
|
|
||||||
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
BRANCH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
|
||||||
PATH_RE = re.compile(r"^[^\\\x00-\x1f\x7f]+$")
|
PATH_RE = re.compile(r"^[^\\\x00-\x1f\x7f]+$")
|
||||||
SENSITIVE_RE = re.compile(
|
SENSITIVE_RE = re.compile(
|
||||||
@@ -104,33 +75,6 @@ class WorkerError(RuntimeError):
|
|||||||
self.details = dict(details or {})
|
self.details = dict(details or {})
|
||||||
|
|
||||||
|
|
||||||
def _isolated_executor_wait_seconds(verification: Sequence[Mapping[str, Any]]) -> int:
|
|
||||||
verification_budget = sum(int(item["timeout_seconds"]) for item in verification)
|
|
||||||
required_wait_seconds = max(30, verification_budget + 30)
|
|
||||||
if not 30 <= ISOLATED_EXECUTOR_TIMEOUT_SECONDS <= ISOLATED_EXECUTOR_TIMEOUT_HARD_MAX_SECONDS:
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_TIMEOUT_CONFIG_INVALID", phase="adapter")
|
|
||||||
if required_wait_seconds > ISOLATED_EXECUTOR_TIMEOUT_SECONDS:
|
|
||||||
raise WorkerError(
|
|
||||||
"ISOLATED_EXECUTOR_TIMEOUT_BUDGET_EXCEEDED",
|
|
||||||
phase="adapter",
|
|
||||||
details={
|
|
||||||
"required_seconds": required_wait_seconds,
|
|
||||||
"configured_seconds": ISOLATED_EXECUTOR_TIMEOUT_SECONDS,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return required_wait_seconds
|
|
||||||
|
|
||||||
|
|
||||||
def _claim_order(item: tuple[Mapping[str, Any], "ExecutionEnvelope"]) -> tuple[int, int, str, str]:
|
|
||||||
task, envelope = item
|
|
||||||
try:
|
|
||||||
priority = int(task.get("priority") or 0)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
priority = 0
|
|
||||||
action_rank = 0 if envelope.action == "deterministic" else 1
|
|
||||||
return (-priority, action_rank, str(task.get("created_at") or ""), envelope.task_id)
|
|
||||||
|
|
||||||
|
|
||||||
def now() -> str:
|
def now() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
@@ -332,18 +276,10 @@ def run_command(
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExecutionEnvelope:
|
class ExecutionEnvelope:
|
||||||
task_id: str
|
task_id: str
|
||||||
job_id: str
|
|
||||||
repository: str
|
repository: str
|
||||||
base_oid: str
|
base_oid: str
|
||||||
expected_branch_oid: str
|
|
||||||
target_branch: str
|
target_branch: str
|
||||||
branch_mode: str
|
|
||||||
generation: int
|
|
||||||
idempotency_key: str
|
|
||||||
action: str
|
|
||||||
adapter: str
|
adapter: str
|
||||||
capability_scope: tuple[str, ...]
|
|
||||||
source_signals: tuple[str, ...]
|
|
||||||
change_set: tuple[dict[str, Any], ...]
|
change_set: tuple[dict[str, Any], ...]
|
||||||
verification: tuple[dict[str, Any], ...]
|
verification: tuple[dict[str, Any], ...]
|
||||||
governance: dict[str, Any]
|
governance: dict[str, Any]
|
||||||
@@ -351,28 +287,18 @@ class ExecutionEnvelope:
|
|||||||
digest: str
|
digest: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_task(
|
def from_task(cls, task: Mapping[str, Any]) -> "ExecutionEnvelope":
|
||||||
cls,
|
|
||||||
task: Mapping[str, Any],
|
|
||||||
execution_override: Mapping[str, Any] | None = None,
|
|
||||||
worker_capabilities: tuple[str, ...] | None = None,
|
|
||||||
) -> "ExecutionEnvelope":
|
|
||||||
task_id = str(task.get("task_id") or "")
|
task_id = str(task.get("task_id") or "")
|
||||||
if not ID_RE.fullmatch(task_id):
|
if not ID_RE.fullmatch(task_id):
|
||||||
raise WorkerError("TASK_ID_INVALID", phase="validation")
|
raise WorkerError("TASK_ID_INVALID", phase="validation")
|
||||||
metadata = task.get("metadata")
|
metadata = task.get("metadata")
|
||||||
if not isinstance(metadata, Mapping):
|
if not isinstance(metadata, Mapping):
|
||||||
raise WorkerError("TASK_METADATA_REQUIRED", phase="validation")
|
raise WorkerError("TASK_METADATA_REQUIRED", phase="validation")
|
||||||
execution = execution_override if execution_override is not None else metadata.get("execution")
|
execution = metadata.get("execution")
|
||||||
if not isinstance(execution, Mapping):
|
if not isinstance(execution, Mapping):
|
||||||
raise WorkerError("DYNAMIC_EXECUTION_PAYLOAD_REQUIRED", phase="validation")
|
raise WorkerError("DYNAMIC_EXECUTION_PAYLOAD_REQUIRED", phase="validation")
|
||||||
try:
|
if execution.get("schema") != "aios.online-agent.execution.v2":
|
||||||
execution = validate_execution_envelope(execution, worker_capabilities=worker_capabilities)
|
raise WorkerError("EXECUTION_SCHEMA_INVALID", phase="validation")
|
||||||
except ExecutionProjectionError as exc:
|
|
||||||
raise WorkerError(str(exc), phase="validation") from exc
|
|
||||||
if execution.get("task_id") != task_id:
|
|
||||||
raise WorkerError("TASK_BINDING_CONFLICT", phase="validation")
|
|
||||||
action = str(execution["action"])
|
|
||||||
repository = execution.get("repository")
|
repository = execution.get("repository")
|
||||||
if not isinstance(repository, str) or not repository:
|
if not isinstance(repository, str) or not repository:
|
||||||
raise WorkerError("REPOSITORY_INVALID", phase="validation")
|
raise WorkerError("REPOSITORY_INVALID", phase="validation")
|
||||||
@@ -393,10 +319,10 @@ class ExecutionEnvelope:
|
|||||||
adapter = str(adapter_value.get("name") or "")
|
adapter = str(adapter_value.get("name") or "")
|
||||||
else:
|
else:
|
||||||
adapter = str(adapter_value or "")
|
adapter = str(adapter_value or "")
|
||||||
if adapter not in {"deterministic", "source_capture", "codex", "hermes"}:
|
if adapter not in {"deterministic", "codex", "hermes"}:
|
||||||
raise WorkerError("RUNTIME_ADAPTER_UNSUPPORTED", phase="validation")
|
raise WorkerError("RUNTIME_ADAPTER_UNSUPPORTED", phase="validation")
|
||||||
change_set = execution.get("change_set")
|
change_set = execution.get("change_set")
|
||||||
if not isinstance(change_set, list) or len(change_set) > 128 or (action == "deterministic" and not change_set):
|
if not isinstance(change_set, list) or not change_set or len(change_set) > 128:
|
||||||
raise WorkerError("CHANGE_SET_INVALID", phase="validation")
|
raise WorkerError("CHANGE_SET_INVALID", phase="validation")
|
||||||
normalized_changes: list[dict[str, Any]] = []
|
normalized_changes: list[dict[str, Any]] = []
|
||||||
for item in change_set:
|
for item in change_set:
|
||||||
@@ -472,23 +398,15 @@ class ExecutionEnvelope:
|
|||||||
governance_value["allowed_files"] = sorted(allowed)
|
governance_value["allowed_files"] = sorted(allowed)
|
||||||
return cls(
|
return cls(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
job_id=str(execution["job_id"]),
|
|
||||||
repository=repository,
|
repository=repository,
|
||||||
base_oid=base_oid,
|
base_oid=base_oid,
|
||||||
expected_branch_oid=str(execution["expected_branch_oid"]),
|
|
||||||
target_branch=target_branch,
|
target_branch=target_branch,
|
||||||
branch_mode=str(execution["branch_mode"]),
|
|
||||||
generation=int(execution["generation"]),
|
|
||||||
idempotency_key=str(execution["idempotency_key"]),
|
|
||||||
action=action,
|
|
||||||
adapter=adapter,
|
adapter=adapter,
|
||||||
capability_scope=tuple(execution.get("capability_scope") or ()),
|
|
||||||
source_signals=tuple(execution.get("source_signals") or ()),
|
|
||||||
change_set=tuple(normalized_changes),
|
change_set=tuple(normalized_changes),
|
||||||
verification=tuple(normalized_verification),
|
verification=tuple(normalized_verification),
|
||||||
governance=governance_value,
|
governance=governance_value,
|
||||||
workspace=workspace_value,
|
workspace=workspace_value,
|
||||||
digest=str(execution["execution_digest"]),
|
digest=digest_json(execution),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -561,81 +479,6 @@ class DeterministicExecutor(RuntimeAdapter):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class SourceCaptureExecutor(RuntimeAdapter):
|
|
||||||
"""Read-only repository source capture for a first implementation attempt."""
|
|
||||||
|
|
||||||
name = "source_capture"
|
|
||||||
roots = ("active-runtime/app", "tests", "migrations", "deploy/systemd")
|
|
||||||
suffixes = {".py", ".sql", ".yaml", ".yml", ".json", ".md", ".service"}
|
|
||||||
defaults = {
|
|
||||||
"agent", "task", "worker", "execution", "projection", "claim", "heartbeat",
|
|
||||||
"governed", "trace", "tool", "credential", "scope", "message", "handoff",
|
|
||||||
}
|
|
||||||
|
|
||||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
|
||||||
terms = set(self.defaults)
|
|
||||||
for signal in envelope.source_signals:
|
|
||||||
terms.update(token.lower() for token in re.findall(r"[A-Za-z][A-Za-z0-9_.-]{2,63}", signal))
|
|
||||||
terms = set(sorted(terms)[:64])
|
|
||||||
selected: list[tuple[str, bytes]] = []
|
|
||||||
total = 0
|
|
||||||
root = worktree.resolve()
|
|
||||||
for relative_root in self.roots:
|
|
||||||
candidate_root = resolve_under(root, relative_root)
|
|
||||||
if not candidate_root.is_dir() or candidate_root.is_symlink():
|
|
||||||
continue
|
|
||||||
for path in sorted(candidate_root.rglob("*")):
|
|
||||||
if len(selected) >= 96 or total >= 3 * 1024 * 1024:
|
|
||||||
break
|
|
||||||
if path.is_symlink() or not path.is_file() or path.suffix.lower() not in self.suffixes:
|
|
||||||
continue
|
|
||||||
resolved = path.resolve(strict=True)
|
|
||||||
if root not in resolved.parents:
|
|
||||||
raise WorkerError("SOURCE_CAPTURE_PATH_ESCAPE", phase="source_capture")
|
|
||||||
data = path.read_bytes()
|
|
||||||
if len(data) > MAX_FILE_BYTES:
|
|
||||||
continue
|
|
||||||
relative = path.relative_to(root).as_posix()
|
|
||||||
searchable = (relative + "\n" + data[:256000].decode("utf-8", "ignore")).lower()
|
|
||||||
if not any(term in searchable for term in terms):
|
|
||||||
continue
|
|
||||||
selected.append((relative, data))
|
|
||||||
total += len(data)
|
|
||||||
if not selected:
|
|
||||||
raise WorkerError("SOURCE_CAPTURE_EMPTY", phase="source_capture")
|
|
||||||
payload = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=payload, mode="w:gz") as archive:
|
|
||||||
for relative, data in selected:
|
|
||||||
info = tarfile.TarInfo(relative)
|
|
||||||
info.size = len(data)
|
|
||||||
info.mode = 0o640
|
|
||||||
info.mtime = 0
|
|
||||||
archive.addfile(info, io.BytesIO(data))
|
|
||||||
compressed = payload.getvalue()
|
|
||||||
if len(compressed) > MAX_TASK_BYTES:
|
|
||||||
raise WorkerError("SOURCE_CAPTURE_TOO_LARGE", phase="source_capture", details={"compressed_bytes": len(compressed)})
|
|
||||||
manifest = [
|
|
||||||
{"path": relative, "bytes": len(data), "sha256": digest_bytes(data), "source": "repository"}
|
|
||||||
for relative, data in selected
|
|
||||||
]
|
|
||||||
audit("ENGINEERING_SOURCE_CAPTURED", file_count=len(manifest), compressed_bytes=len(compressed))
|
|
||||||
return {
|
|
||||||
"changed_files": [],
|
|
||||||
"commands": [],
|
|
||||||
"tests": [],
|
|
||||||
"adapter": self.name,
|
|
||||||
"source_snapshot": {
|
|
||||||
"schema": "aios.engineering.source-snapshot.v2",
|
|
||||||
"encoding": "tar.gz+base64",
|
|
||||||
"payload_b64": base64.b64encode(compressed).decode("ascii"),
|
|
||||||
"payload_sha256": digest_bytes(compressed),
|
|
||||||
"compressed_bytes": len(compressed),
|
|
||||||
"file_count": len(manifest),
|
|
||||||
"files": manifest,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class CodexExecutor(RuntimeAdapter):
|
class CodexExecutor(RuntimeAdapter):
|
||||||
name = "codex"
|
name = "codex"
|
||||||
|
|
||||||
@@ -659,12 +502,7 @@ class HermesExecutor(RuntimeAdapter):
|
|||||||
|
|
||||||
|
|
||||||
def adapter_for(name: str) -> RuntimeAdapter:
|
def adapter_for(name: str) -> RuntimeAdapter:
|
||||||
return {
|
return {"deterministic": DeterministicExecutor, "codex": CodexExecutor, "hermes": HermesExecutor}[name]()
|
||||||
"deterministic": IsolatedDeterministicExecutor,
|
|
||||||
"source_capture": SourceCaptureExecutor,
|
|
||||||
"codex": CodexExecutor,
|
|
||||||
"hermes": HermesExecutor,
|
|
||||||
}[name]()
|
|
||||||
|
|
||||||
|
|
||||||
class HttpClient:
|
class HttpClient:
|
||||||
@@ -706,9 +544,6 @@ class AgentTaskClient:
|
|||||||
def claim(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
def claim(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
||||||
return HttpClient.call(API_BASE, "POST", f"/v1/tasks/{task_id}/claim", self.token, {"lease_seconds": LEASE_SECONDS})
|
return HttpClient.call(API_BASE, "POST", f"/v1/tasks/{task_id}/claim", self.token, {"lease_seconds": LEASE_SECONDS})
|
||||||
|
|
||||||
def get(self, task_id: str) -> tuple[int, dict[str, Any]]:
|
|
||||||
return HttpClient.call(API_BASE, "GET", f"/v1/tasks/{task_id}", self.token)
|
|
||||||
|
|
||||||
def heartbeat(self, task_id: str, lease_token: str, stage: str) -> tuple[int, dict[str, Any]]:
|
def heartbeat(self, task_id: str, lease_token: str, stage: str) -> tuple[int, dict[str, Any]]:
|
||||||
return HttpClient.call(
|
return HttpClient.call(
|
||||||
API_BASE,
|
API_BASE,
|
||||||
@@ -729,20 +564,8 @@ class AgentTaskClient:
|
|||||||
|
|
||||||
|
|
||||||
class BridgeClient:
|
class BridgeClient:
|
||||||
def __init__(self, token: str) -> None:
|
|
||||||
self.token = token
|
|
||||||
|
|
||||||
def call(self, route: str, body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
def call(self, route: str, body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||||
return HttpClient.call(BRIDGE_BASE, "POST", route, self.token, body, timeout=60)
|
return HttpClient.call(BRIDGE_BASE, "POST", route, None, body, timeout=60)
|
||||||
|
|
||||||
def project_execution(self, task: Mapping[str, Any], execution: Mapping[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
|
||||||
request: dict[str, Any] = {
|
|
||||||
"task_id": str(task.get("task_id") or ""),
|
|
||||||
"required_capabilities": list(task.get("required_capabilities") or []),
|
|
||||||
}
|
|
||||||
if execution is not None:
|
|
||||||
request["execution"] = dict(execution)
|
|
||||||
return self.call("/v2/execution-envelope", request)
|
|
||||||
|
|
||||||
|
|
||||||
class LeaseHeartbeat:
|
class LeaseHeartbeat:
|
||||||
@@ -829,15 +652,7 @@ def task_state_path(task_id: str) -> Path:
|
|||||||
return STATE_ROOT / "tasks" / f"{task_id}.json"
|
return STATE_ROOT / "tasks" / f"{task_id}.json"
|
||||||
|
|
||||||
|
|
||||||
def load_task_state(
|
def load_task_state(task_id: str, execution_digest: str) -> dict[str, Any] | None:
|
||||||
task_id: str,
|
|
||||||
execution_digest: str,
|
|
||||||
*,
|
|
||||||
attempt: int | None = None,
|
|
||||||
continuation_head_oid: str | None = None,
|
|
||||||
continuation_job_id: str | None = None,
|
|
||||||
commit_idempotency_key: str | None = None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
path = task_state_path(task_id)
|
path = task_state_path(task_id)
|
||||||
if not path.is_file() or path.is_symlink():
|
if not path.is_file() or path.is_symlink():
|
||||||
return None
|
return None
|
||||||
@@ -848,55 +663,6 @@ def load_task_state(
|
|||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise WorkerError("TASK_STATE_INVALID", phase="state")
|
raise WorkerError("TASK_STATE_INVALID", phase="state")
|
||||||
if value.get("execution_digest") not in {None, execution_digest}:
|
if value.get("execution_digest") not in {None, execution_digest}:
|
||||||
if value.get("phase") in {"claimed", "workspace_prepared", "source_capture_done"} and not value.get("governed"):
|
|
||||||
return None
|
|
||||||
if (
|
|
||||||
value.get("phase") == "verification_passed"
|
|
||||||
and not value.get("governed")
|
|
||||||
and attempt is not None
|
|
||||||
and int(value.get("attempt") or 0) < int(attempt)
|
|
||||||
):
|
|
||||||
# The prior official attempt completed deterministic validation but failed before
|
|
||||||
# a WriterLease or governed commit. A later CAS-authorized retry may use its new
|
|
||||||
# digest; the same attempt can never erase or replace this state.
|
|
||||||
return None
|
|
||||||
if value.get("phase") == "reconciliation_required" and not value.get("governed"):
|
|
||||||
stored_attempt = value.get("attempt")
|
|
||||||
if (
|
|
||||||
stored_attempt is not None
|
|
||||||
and attempt is not None
|
|
||||||
and int(stored_attempt) < int(attempt)
|
|
||||||
and value.get("job_id") == continuation_job_id
|
|
||||||
and isinstance(continuation_head_oid, str)
|
|
||||||
and OID_RE.fullmatch(continuation_head_oid)
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
details = value.get("details")
|
|
||||||
if (
|
|
||||||
stored_attempt is None
|
|
||||||
and attempt is not None
|
|
||||||
and isinstance(details, Mapping)
|
|
||||||
and details.get("api_status") == 503
|
|
||||||
and details.get("error") == "WORKTREE_DIRTY_SCOPE_MISMATCH"
|
|
||||||
and value.get("job_id") == continuation_job_id
|
|
||||||
and isinstance(continuation_head_oid, str)
|
|
||||||
and OID_RE.fullmatch(continuation_head_oid)
|
|
||||||
):
|
|
||||||
# Compatibility for the pre-fix recovery record written by attempt 18,
|
|
||||||
# which omitted its attempt number. This exact pre-commit failure has no
|
|
||||||
# governed side effect; plan-registry CAS and the continuation head still
|
|
||||||
# bind the later official claim before the stale state can be discarded.
|
|
||||||
return None
|
|
||||||
governed = value.get("governed")
|
|
||||||
if (
|
|
||||||
continuation_head_oid
|
|
||||||
and isinstance(governed, Mapping)
|
|
||||||
and governed.get("commit_oid") == continuation_head_oid
|
|
||||||
and governed.get("commit_idempotency_key") != commit_idempotency_key
|
|
||||||
):
|
|
||||||
# A previous governed commit is the exact continuation head; a new digest/key is a
|
|
||||||
# legitimate next deterministic plan on the same Task/Job lineage.
|
|
||||||
return None
|
|
||||||
raise WorkerError("TASK_PAYLOAD_DRIFT", phase="recovery")
|
raise WorkerError("TASK_PAYLOAD_DRIFT", phase="recovery")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -921,191 +687,35 @@ def cleanup_run_root(path: Path) -> None:
|
|||||||
shutil.rmtree(candidate)
|
shutil.rmtree(candidate)
|
||||||
|
|
||||||
|
|
||||||
def prepare_worktree(run_root: Path, envelope: ExecutionEnvelope) -> Path:
|
def prepare_worktree(run_root: Path, base_oid: str) -> Path:
|
||||||
clone = run_root / "repository"
|
clone = run_root / "repository"
|
||||||
worktree = run_root / "worktree"
|
worktree = run_root / "worktree"
|
||||||
worktree_oid = (
|
|
||||||
envelope.expected_branch_oid
|
|
||||||
if envelope.branch_mode == "CONTINUE_EXISTING"
|
|
||||||
else envelope.base_oid
|
|
||||||
)
|
|
||||||
run_command("workspace_clone", ["/usr/bin/git", "clone", "--no-local", "--no-hardlinks", str(AUTHORITY_GIT_DIR), str(clone)], cwd=run_root, timeout=300, internal_git=True)
|
run_command("workspace_clone", ["/usr/bin/git", "clone", "--no-local", "--no-hardlinks", str(AUTHORITY_GIT_DIR), str(clone)], cwd=run_root, timeout=300, internal_git=True)
|
||||||
run_command("workspace_base_probe", ["/usr/bin/git", "-C", str(clone), "rev-parse", "--verify", worktree_oid + "^{commit}"], cwd=run_root, timeout=60)
|
run_command("workspace_base_probe", ["/usr/bin/git", "-C", str(clone), "rev-parse", "--verify", base_oid + "^{commit}"], cwd=run_root, timeout=60)
|
||||||
run_command("workspace_worktree_create", ["/usr/bin/git", "-C", str(clone), "worktree", "add", "--detach", str(worktree), worktree_oid], cwd=run_root, timeout=300, internal_git=True)
|
run_command("workspace_worktree_create", ["/usr/bin/git", "-C", str(clone), "worktree", "add", "--detach", str(worktree), base_oid], cwd=run_root, timeout=300, internal_git=True)
|
||||||
return worktree
|
return worktree
|
||||||
|
|
||||||
|
|
||||||
def _archive_worktree(worktree: Path, archive_path: Path) -> str:
|
|
||||||
with tarfile.open(archive_path, mode="w:gz") as archive:
|
|
||||||
for path in sorted(worktree.rglob("*")):
|
|
||||||
relative = path.relative_to(worktree)
|
|
||||||
if ".git" in relative.parts or path.is_symlink() or not path.is_file():
|
|
||||||
continue
|
|
||||||
data = path.read_bytes()
|
|
||||||
info = tarfile.TarInfo(relative.as_posix())
|
|
||||||
info.size = len(data)
|
|
||||||
info.mode = 0o640
|
|
||||||
info.mtime = 0
|
|
||||||
archive.addfile(info, io.BytesIO(data))
|
|
||||||
archive_path.chmod(0o640)
|
|
||||||
return digest_bytes(archive_path.read_bytes())
|
|
||||||
|
|
||||||
|
|
||||||
class IsolatedDeterministicExecutor(RuntimeAdapter):
|
|
||||||
"""Coordinator-side client for the uncredentialed executor exchange."""
|
|
||||||
|
|
||||||
name = "isolated-deterministic"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _atomic(path: Path, data: bytes) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent))
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "wb") as handle:
|
|
||||||
handle.write(data)
|
|
||||||
handle.flush()
|
|
||||||
os.fsync(handle.fileno())
|
|
||||||
os.chmod(temporary, 0o640)
|
|
||||||
os.replace(temporary, path)
|
|
||||||
finally:
|
|
||||||
if os.path.exists(temporary):
|
|
||||||
os.unlink(temporary)
|
|
||||||
|
|
||||||
def execute(self, envelope: ExecutionEnvelope, worktree: Path) -> dict[str, Any]:
|
|
||||||
if envelope.action != "deterministic":
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_ACTION_INVALID", phase="adapter")
|
|
||||||
root = ISOLATED_EXECUTOR_ROOT
|
|
||||||
for child in ("requests", "results", "failed", "sources"):
|
|
||||||
candidate = root / child
|
|
||||||
if candidate.is_symlink() or not candidate.is_dir():
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_UNAVAILABLE", phase="adapter", details={"path": str(candidate)})
|
|
||||||
request_id = f"{envelope.task_id}.{secrets.token_hex(16)}"
|
|
||||||
if not REQUEST_ID_RE.fullmatch(request_id):
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_REQUEST_ID_INVALID", phase="adapter")
|
|
||||||
archive = root / "sources" / f"{request_id}.tgz"
|
|
||||||
request_path = root / "requests" / f"{request_id}.json"
|
|
||||||
result_path = root / "results" / f"{request_id}.json"
|
|
||||||
failed_path = root / "failed" / f"{request_id}.json"
|
|
||||||
for stale in (request_path, result_path, failed_path, archive):
|
|
||||||
if stale.exists() or stale.is_symlink():
|
|
||||||
raise WorkerError(
|
|
||||||
"ISOLATED_EXECUTOR_COLLISION_DETECTED", phase="adapter", details={"request_id": request_id, "path": str(stale)}
|
|
||||||
)
|
|
||||||
archive_sha = _archive_worktree(worktree, archive)
|
|
||||||
request = {
|
|
||||||
"schema": "aios.isolated-execution.request.v1",
|
|
||||||
"request_id": request_id,
|
|
||||||
"task_id": envelope.task_id,
|
|
||||||
"job_id": envelope.job_id,
|
|
||||||
"execution_digest": envelope.digest,
|
|
||||||
"source_archive": str(archive),
|
|
||||||
"source_archive_sha256": archive_sha,
|
|
||||||
"change_set": list(envelope.change_set),
|
|
||||||
"verification": list(envelope.verification),
|
|
||||||
}
|
|
||||||
request_sha = digest_json(request)
|
|
||||||
self._atomic(request_path, json.dumps(request, sort_keys=True, separators=(",", ":")).encode("utf-8"))
|
|
||||||
wait_seconds = _isolated_executor_wait_seconds(envelope.verification)
|
|
||||||
deadline = time.monotonic() + wait_seconds
|
|
||||||
try:
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if failed_path.is_file():
|
|
||||||
details = json.loads(failed_path.read_text(encoding="utf-8"))
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_FAILED", phase="adapter", details={"error": details.get("error")})
|
|
||||||
if result_path.is_file():
|
|
||||||
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
||||||
if result.get("request_id") != request_id:
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_REQUEST_ID_MISMATCH", phase="adapter")
|
|
||||||
if result.get("schema") != "aios.isolated-execution.result.v1":
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_RESULT_INVALID", phase="adapter")
|
|
||||||
if result.get("request_sha256") != request_sha:
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_REQUEST_BINDING_INVALID", phase="adapter")
|
|
||||||
material = {key: value for key, value in result.items() if key != "result_sha256"}
|
|
||||||
if result.get("result_sha256") != digest_json(material):
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_RESULT_DIGEST_INVALID", phase="adapter")
|
|
||||||
isolation = result.get("credential_isolation")
|
|
||||||
if not isinstance(isolation, Mapping) or not all(isolation.get(k) is True for k in (
|
|
||||||
"credential_env_absent", "credential_mount_inaccessible", "governed_bridge_unreachable"
|
|
||||||
)):
|
|
||||||
raise WorkerError("CREDENTIAL_ISOLATION_VERIFY", phase="adapter")
|
|
||||||
expected_paths = sorted({str(item["path"]) for item in envelope.change_set})
|
|
||||||
returned = result.get("changed_files")
|
|
||||||
if not isinstance(returned, list) or sorted(str(item.get("path")) for item in returned) != expected_paths:
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_CHANGED_FILESET_INVALID", phase="adapter")
|
|
||||||
for item in returned:
|
|
||||||
relative = safe_relative_path(item.get("path"), field="isolated_changed_file")
|
|
||||||
path = resolve_under(worktree, relative, write=True)
|
|
||||||
if item.get("deleted") is True:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
continue
|
|
||||||
raw = base64.b64decode(str(item.get("content_b64") or ""), validate=True)
|
|
||||||
if digest_bytes(raw) != item.get("sha256"):
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_FILE_DIGEST_INVALID", phase="adapter")
|
|
||||||
self._atomic(path, raw)
|
|
||||||
commands = [
|
|
||||||
run_command("git_diff_check", ["/usr/bin/git", "-C", str(worktree), "diff", "--check"], cwd=worktree, timeout=120),
|
|
||||||
run_command("git_diff_stat", ["/usr/bin/git", "-C", str(worktree), "diff", "--no-ext-diff", "--stat"], cwd=worktree, timeout=120),
|
|
||||||
]
|
|
||||||
return {
|
|
||||||
"changed_files": expected_paths,
|
|
||||||
"commands": commands,
|
|
||||||
"tests": list(result.get("verification") or []),
|
|
||||||
"adapter": self.name,
|
|
||||||
"credential_isolation": dict(isolation),
|
|
||||||
}
|
|
||||||
time.sleep(0.25)
|
|
||||||
raise WorkerError("ISOLATED_EXECUTOR_TIMEOUT", phase="adapter")
|
|
||||||
finally:
|
|
||||||
request_path.unlink(missing_ok=True)
|
|
||||||
result_path.unlink(missing_ok=True)
|
|
||||||
failed_path.unlink(missing_ok=True)
|
|
||||||
archive.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _verify_converged_job(
|
|
||||||
response: Mapping[str, Any],
|
|
||||||
envelope: ExecutionEnvelope,
|
|
||||||
commit_oid: str,
|
|
||||||
) -> None:
|
|
||||||
if response.get("state") != "COMPLETED":
|
|
||||||
raise WorkerError("GOVERNED_RECEIPT_STATE_INVALID", phase="verification")
|
|
||||||
receipt = response.get("receipt")
|
|
||||||
if not isinstance(receipt, Mapping) or receipt.get("remote_verified") is not True:
|
|
||||||
raise WorkerError("GOVERNED_RECEIPT_REQUIRED", phase="verification")
|
|
||||||
for field in ("authority_oid", "api_branch_oid", "git_remote_oid"):
|
|
||||||
if receipt.get(field) != commit_oid:
|
|
||||||
raise WorkerError("GOVERNED_RECEIPT_OID_MISMATCH", phase="verification", details={"field": field})
|
|
||||||
leases = response.get("branch_leases")
|
|
||||||
if not isinstance(leases, list):
|
|
||||||
raise WorkerError("WRITER_LEASE_RECOVERY_EVIDENCE_MISSING", phase="verification")
|
|
||||||
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
|
|
||||||
]
|
|
||||||
if len(active) != 1:
|
|
||||||
raise WorkerError("WRITER_LEASE_RECOVERY_EVIDENCE_INVALID", phase="verification")
|
|
||||||
lease = active[0]
|
|
||||||
if (
|
|
||||||
int(lease.get("generation") or 0) != envelope.generation
|
|
||||||
or lease.get("current_head_oid") != commit_oid
|
|
||||||
):
|
|
||||||
raise WorkerError("WRITER_LEASE_CURRENT_HEAD_MISMATCH", phase="verification")
|
|
||||||
|
|
||||||
|
|
||||||
def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeClient, heartbeat: LeaseHeartbeat) -> dict[str, Any]:
|
def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeClient, heartbeat: LeaseHeartbeat) -> dict[str, Any]:
|
||||||
governance = envelope.governance
|
governance = envelope.governance
|
||||||
if governance.get("task_id") != envelope.task_id or governance.get("job_id") != envelope.job_id:
|
create_body = {
|
||||||
raise WorkerError("GOVERNED_LINEAGE_INVALID", phase="committing")
|
"task_id": governance["task_id"],
|
||||||
generation = envelope.generation
|
"repository": envelope.repository,
|
||||||
expected_branch_oid = envelope.expected_branch_oid
|
"base_oid": envelope.base_oid,
|
||||||
branch_mode = envelope.branch_mode
|
"target_branch": envelope.target_branch,
|
||||||
|
"risk": str(governance.get("risk") or "engineering"),
|
||||||
|
"description": str(governance.get("description") or f"Governed execution for {envelope.task_id}"),
|
||||||
|
"idempotency_key": governance["idempotency_key"],
|
||||||
|
}
|
||||||
|
status, response = bridge.call("/v2/governed-task", create_body)
|
||||||
|
if not 200 <= status < 300:
|
||||||
|
raise WorkerError("GOVERNED_TASK_CREATE_FAILED", phase="committing", details={"api_status": status, "error": response.get("error")})
|
||||||
|
generation = int(response.get("generation") or 0)
|
||||||
|
expected_branch_oid = str(response.get("expected_branch_oid") or envelope.base_oid)
|
||||||
|
branch_mode = str(response.get("branch_mode") or "CREATE_NEW")
|
||||||
lease_body = {
|
lease_body = {
|
||||||
"schema": "aios.task-writer-lease.v2",
|
"schema": "aios.task-writer-lease.v2",
|
||||||
"task_id": envelope.task_id,
|
"task_id": governance["task_id"],
|
||||||
"job_id": envelope.job_id,
|
|
||||||
"repository": envelope.repository,
|
"repository": envelope.repository,
|
||||||
"branch_mode": branch_mode,
|
"branch_mode": branch_mode,
|
||||||
"base_oid": envelope.base_oid,
|
"base_oid": envelope.base_oid,
|
||||||
@@ -1117,17 +727,6 @@ def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeC
|
|||||||
status, lease_response = bridge.call("/v2/writer-lease", lease_body)
|
status, lease_response = bridge.call("/v2/writer-lease", lease_body)
|
||||||
if not 200 <= status < 300:
|
if not 200 <= status < 300:
|
||||||
raise WorkerError("WRITER_LEASE_ACQUIRE_FAILED", phase="committing", details={"api_status": status, "error": lease_response.get("error")})
|
raise WorkerError("WRITER_LEASE_ACQUIRE_FAILED", phase="committing", details={"api_status": status, "error": lease_response.get("error")})
|
||||||
if (
|
|
||||||
lease_response.get("job_id") != envelope.job_id
|
|
||||||
or lease_response.get("task_id") != envelope.task_id
|
|
||||||
or lease_response.get("repository") != envelope.repository
|
|
||||||
or lease_response.get("base_oid") != envelope.base_oid
|
|
||||||
or lease_response.get("expected_branch_oid") != expected_branch_oid
|
|
||||||
or lease_response.get("branch_mode") != branch_mode
|
|
||||||
or int(lease_response.get("generation") or 0) != generation
|
|
||||||
or lease_response.get("target_branch") != envelope.target_branch
|
|
||||||
):
|
|
||||||
raise WorkerError("WRITER_LEASE_RESPONSE_BINDING_INVALID", phase="committing")
|
|
||||||
heartbeat.ensure()
|
heartbeat.ensure()
|
||||||
files: dict[str, str] = {}
|
files: dict[str, str] = {}
|
||||||
paths = governance.get("allowed_files") or [item["path"] for item in envelope.change_set]
|
paths = governance.get("allowed_files") or [item["path"] for item in envelope.change_set]
|
||||||
@@ -1137,8 +736,7 @@ def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeC
|
|||||||
raise WorkerError("COMMIT_FILE_MISSING", phase="committing", details={"path": relative})
|
raise WorkerError("COMMIT_FILE_MISSING", phase="committing", details={"path": relative})
|
||||||
files[relative] = base64.b64encode(path.read_bytes()).decode("ascii")
|
files[relative] = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||||
commit_body = {
|
commit_body = {
|
||||||
"task_id": envelope.task_id,
|
"task_id": governance["task_id"],
|
||||||
"job_id": envelope.job_id,
|
|
||||||
"repository": envelope.repository,
|
"repository": envelope.repository,
|
||||||
"expected_branch_oid": expected_branch_oid,
|
"expected_branch_oid": expected_branch_oid,
|
||||||
"target_branch": envelope.target_branch,
|
"target_branch": envelope.target_branch,
|
||||||
@@ -1149,25 +747,12 @@ def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeC
|
|||||||
}
|
}
|
||||||
status, commit_response = bridge.call("/v2/commit-files", commit_body)
|
status, commit_response = bridge.call("/v2/commit-files", commit_body)
|
||||||
if not 200 <= status < 300:
|
if not 200 <= status < 300:
|
||||||
code = "RECONCILIATION_REQUIRED" if status == 599 or status >= 500 else "GOVERNED_COMMIT_FAILED"
|
raise WorkerError("GOVERNED_COMMIT_FAILED", phase="committing", details={"api_status": status, "error": commit_response.get("error")})
|
||||||
raise WorkerError(code, phase="committing", details={"api_status": status, "error": commit_response.get("error"), "job_id": envelope.job_id})
|
|
||||||
if (
|
|
||||||
commit_response.get("job_id") != envelope.job_id
|
|
||||||
or commit_response.get("target_branch") != envelope.target_branch
|
|
||||||
or int(commit_response.get("generation") or 0) != generation
|
|
||||||
or commit_response.get("branch_mode") not in {"CREATE_NEW", "CONTINUE_EXISTING"}
|
|
||||||
):
|
|
||||||
raise WorkerError("GOVERNED_COMMIT_RESPONSE_BINDING_INVALID", phase="committing")
|
|
||||||
commit_oid = commit_response.get("commit_oid")
|
commit_oid = commit_response.get("commit_oid")
|
||||||
if not isinstance(commit_oid, str) or not OID_RE.fullmatch(commit_oid):
|
if not isinstance(commit_oid, str) or not OID_RE.fullmatch(commit_oid):
|
||||||
raise WorkerError("GOVERNED_COMMIT_OID_INVALID", phase="committing")
|
raise WorkerError("GOVERNED_COMMIT_OID_INVALID", phase="committing")
|
||||||
receipt = commit_response.get("receipt")
|
|
||||||
if not isinstance(receipt, Mapping) or receipt.get("remote_verified") is not True:
|
|
||||||
raise WorkerError("GOVERNED_COMMIT_RECEIPT_REQUIRED", phase="verification")
|
|
||||||
visible_oid: str | None = None
|
visible_oid: str | None = None
|
||||||
deadline = time.monotonic() + max(5, min(GIT_VISIBILITY_TIMEOUT_SECONDS, 120))
|
for _ in range(5):
|
||||||
delay = 0.5
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
probe = subprocess.run(
|
probe = subprocess.run(
|
||||||
["/usr/bin/git", "--git-dir", str(AUTHORITY_GIT_DIR), "rev-parse", "--verify", f"refs/heads/{envelope.target_branch}"],
|
["/usr/bin/git", "--git-dir", str(AUTHORITY_GIT_DIR), "rev-parse", "--verify", f"refs/heads/{envelope.target_branch}"],
|
||||||
text=True,
|
text=True,
|
||||||
@@ -1180,17 +765,11 @@ def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeC
|
|||||||
visible_oid = probe.stdout.strip()
|
visible_oid = probe.stdout.strip()
|
||||||
if visible_oid == commit_oid:
|
if visible_oid == commit_oid:
|
||||||
break
|
break
|
||||||
time.sleep(delay)
|
time.sleep(1)
|
||||||
delay = min(delay * 1.7, 5.0)
|
|
||||||
if visible_oid != commit_oid:
|
if visible_oid != commit_oid:
|
||||||
raise WorkerError("RECONCILIATION_REQUIRED", phase="verification", details={"commit_oid": commit_oid, "visible_oid": visible_oid, "job_id": envelope.job_id})
|
raise WorkerError("GIT_VISIBILITY_MISMATCH", phase="verification", details={"commit_oid": commit_oid, "visible_oid": visible_oid})
|
||||||
job_status, job_response = bridge.call("/v2/job", {"job_id": envelope.job_id})
|
|
||||||
if not 200 <= job_status < 300:
|
|
||||||
raise WorkerError("RECONCILIATION_REQUIRED", phase="verification", details={"api_status": job_status, "job_id": envelope.job_id})
|
|
||||||
_verify_converged_job(job_response, envelope, commit_oid)
|
|
||||||
return {
|
return {
|
||||||
"governed_task_id": envelope.task_id,
|
"governed_task_id": governance["task_id"],
|
||||||
"job_id": envelope.job_id,
|
|
||||||
"branch_mode": branch_mode,
|
"branch_mode": branch_mode,
|
||||||
"generation": generation,
|
"generation": generation,
|
||||||
"target_branch": envelope.target_branch,
|
"target_branch": envelope.target_branch,
|
||||||
@@ -1198,7 +777,6 @@ def governed_commit(envelope: ExecutionEnvelope, worktree: Path, bridge: BridgeC
|
|||||||
"visible_oid": visible_oid,
|
"visible_oid": visible_oid,
|
||||||
"commit_idempotency_key": governance["commit_idempotency_key"],
|
"commit_idempotency_key": governance["commit_idempotency_key"],
|
||||||
"api_commit_visible": commit_response.get("commit_visible"),
|
"api_commit_visible": commit_response.get("commit_visible"),
|
||||||
"receipt": dict(commit_response.get("receipt")) if isinstance(commit_response.get("receipt"), Mapping) else None,
|
|
||||||
"direct_main_write": False,
|
"direct_main_write": False,
|
||||||
"force_push": False,
|
"force_push": False,
|
||||||
"production_write": False,
|
"production_write": False,
|
||||||
@@ -1230,86 +808,12 @@ def build_result(task_id: str, envelope: ExecutionEnvelope, execution: Mapping[s
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_source_capture_result(
|
|
||||||
task_id: str,
|
|
||||||
envelope: ExecutionEnvelope,
|
|
||||||
execution: Mapping[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
snapshot = execution.get("source_snapshot")
|
|
||||||
if not isinstance(snapshot, Mapping):
|
|
||||||
raise WorkerError("SOURCE_CAPTURE_RESULT_INVALID", phase="result")
|
|
||||||
return {
|
|
||||||
"status": "SUCCEEDED",
|
|
||||||
"classification": "NEEDS_REWORK_SOURCE_CAPTURE",
|
|
||||||
"verification_state": "NEEDS_REWORK",
|
|
||||||
"execution_mode": "source_capture",
|
|
||||||
"execution_backend": "generic-online-agent-worker-v2",
|
|
||||||
"task_id": task_id,
|
|
||||||
"job_id": envelope.job_id,
|
|
||||||
"repository": envelope.repository,
|
|
||||||
"base_oid": envelope.base_oid,
|
|
||||||
"expected_branch_oid": envelope.expected_branch_oid,
|
|
||||||
"target_branch": envelope.target_branch,
|
|
||||||
"generation": envelope.generation,
|
|
||||||
"execution_digest": envelope.digest,
|
|
||||||
"changed_files": [],
|
|
||||||
"tests": [],
|
|
||||||
"source_snapshot": dict(snapshot),
|
|
||||||
"workspace_cleanup": "PASS",
|
|
||||||
"writer_lease": "NONE",
|
|
||||||
"secret_material_exposed": False,
|
|
||||||
"direct_main_write": False,
|
|
||||||
"force_push": False,
|
|
||||||
"production_write": False,
|
|
||||||
"protected_ref_bypass": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerSupervisor:
|
class WorkerSupervisor:
|
||||||
def __init__(self, token: str) -> None:
|
def __init__(self, token: str) -> None:
|
||||||
self.agent = AgentTaskClient(token)
|
self.agent = AgentTaskClient(token)
|
||||||
self.bridge = BridgeClient(token)
|
self.bridge = BridgeClient()
|
||||||
|
|
||||||
def resolve_envelope(self, task: Mapping[str, Any]) -> tuple[ExecutionEnvelope, str]:
|
def process_claim(self, task: Mapping[str, Any], claim: Mapping[str, Any]) -> None:
|
||||||
metadata = task.get("metadata")
|
|
||||||
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
|
||||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
|
||||||
if not isinstance(execution, Mapping):
|
|
||||||
raise WorkerError("EXECUTION_ENVELOPE_SCHEMA_VALIDATION", phase="validation")
|
|
||||||
persisted = ExecutionEnvelope.from_task(task, worker_capabilities=WORKER_CAPABILITIES)
|
|
||||||
status, authoritative = self.bridge.project_execution(task, execution)
|
|
||||||
if not 200 <= status < 300:
|
|
||||||
raise WorkerError("TASK_PERSISTED_BINDING_UNVERIFIED", phase="projection", details={"api_status": status, "error": authoritative.get("error")})
|
|
||||||
canonical = ExecutionEnvelope.from_task(task, authoritative, WORKER_CAPABILITIES)
|
|
||||||
if canonical.digest != persisted.digest or canonical.job_id != persisted.job_id:
|
|
||||||
raise WorkerError("TASK_PERSISTED_BINDING_CONFLICT", phase="projection")
|
|
||||||
return persisted, "persisted_verified"
|
|
||||||
status, projected = self.bridge.project_execution(task)
|
|
||||||
if status == 404 or projected.get("error") == "TASK_EXECUTION_PROJECTION_MISSING":
|
|
||||||
raise WorkerError("TASK_SKIPPED_PROJECTION_MISSING", phase="projection")
|
|
||||||
if not 200 <= status < 300:
|
|
||||||
raise WorkerError(
|
|
||||||
"TASK_SKIPPED_POLICY_DENY",
|
|
||||||
phase="projection",
|
|
||||||
details={"api_status": status, "error": projected.get("error")},
|
|
||||||
)
|
|
||||||
envelope = ExecutionEnvelope.from_task(task, projected, WORKER_CAPABILITIES)
|
|
||||||
audit(
|
|
||||||
"TASK_EXECUTION_ENVELOPE_PROJECTED",
|
|
||||||
task_id=envelope.task_id,
|
|
||||||
job_id=envelope.job_id,
|
|
||||||
generation=envelope.generation,
|
|
||||||
action=envelope.action,
|
|
||||||
execution_digest=envelope.digest,
|
|
||||||
)
|
|
||||||
return envelope, "recovered_projection"
|
|
||||||
|
|
||||||
def process_claim(
|
|
||||||
self,
|
|
||||||
task: Mapping[str, Any],
|
|
||||||
claim: Mapping[str, Any],
|
|
||||||
envelope: ExecutionEnvelope,
|
|
||||||
) -> None:
|
|
||||||
task_id = str(claim.get("task_id") or task.get("task_id") or "")
|
task_id = str(claim.get("task_id") or task.get("task_id") or "")
|
||||||
lease_token = claim.get("lease_token")
|
lease_token = claim.get("lease_token")
|
||||||
if not isinstance(lease_token, str) or len(lease_token) < 24:
|
if not isinstance(lease_token, str) or len(lease_token) < 24:
|
||||||
@@ -1321,31 +825,10 @@ class WorkerSupervisor:
|
|||||||
heartbeat.set_stage("started")
|
heartbeat.set_stage("started")
|
||||||
heartbeat.start()
|
heartbeat.start()
|
||||||
audit("TASK_STARTED", task_id=task_id, attempt=claim.get("attempt"))
|
audit("TASK_STARTED", task_id=task_id, attempt=claim.get("attempt"))
|
||||||
status, current_task = self.agent.get(task_id)
|
envelope = ExecutionEnvelope.from_task(task)
|
||||||
if status != 200:
|
state = load_task_state(task_id, envelope.digest) or {"task_id": task_id, "execution_digest": envelope.digest, "phase": "claimed"}
|
||||||
raise WorkerError("TASK_READ_AFTER_CLAIM_FAILED", phase="claim", details={"api_status": status})
|
|
||||||
current_envelope, _ = self.resolve_envelope(current_task)
|
|
||||||
if current_envelope.digest != envelope.digest or current_envelope.job_id != envelope.job_id:
|
|
||||||
raise WorkerError("TASK_PAYLOAD_DRIFT", phase="claim")
|
|
||||||
state = load_task_state(
|
|
||||||
task_id,
|
|
||||||
envelope.digest,
|
|
||||||
attempt=int(claim.get("attempt") or 0),
|
|
||||||
continuation_head_oid=envelope.expected_branch_oid if envelope.branch_mode == "CONTINUE_EXISTING" else None,
|
|
||||||
continuation_job_id=envelope.job_id,
|
|
||||||
commit_idempotency_key=str(envelope.governance.get("commit_idempotency_key") or ""),
|
|
||||||
) or {"task_id": task_id, "execution_digest": envelope.digest, "phase": "claimed"}
|
|
||||||
governed = state.get("governed") if isinstance(state.get("governed"), Mapping) else None
|
governed = state.get("governed") if isinstance(state.get("governed"), Mapping) else None
|
||||||
if envelope.action == "deterministic" and isinstance(governed, Mapping) and isinstance(governed.get("commit_oid"), str) and OID_RE.fullmatch(governed["commit_oid"]):
|
if isinstance(governed, Mapping) and isinstance(governed.get("commit_oid"), str) and OID_RE.fullmatch(governed["commit_oid"]):
|
||||||
if governed.get("job_id") != envelope.job_id or governed.get("target_branch") != envelope.target_branch or int(governed.get("generation") or 0) != envelope.generation or not isinstance(governed.get("receipt"), Mapping):
|
|
||||||
raise WorkerError("RECOVERY_EVIDENCE_BINDING_INVALID", phase="recovery")
|
|
||||||
job_status, job_receipt = self.bridge.call("/v2/job", {"job_id": envelope.job_id})
|
|
||||||
if not 200 <= job_status < 300:
|
|
||||||
raise WorkerError("RECOVERY_RECEIPT_UNVERIFIED", phase="recovery", details={"api_status": job_status})
|
|
||||||
_verify_converged_job(job_receipt, envelope, str(governed["commit_oid"]))
|
|
||||||
probe = subprocess.run(["/usr/bin/git", "--git-dir", str(AUTHORITY_GIT_DIR), "rev-parse", "--verify", f"refs/heads/{envelope.target_branch}"], text=True, capture_output=True, check=False, env={"PATH": "/usr/bin:/bin", "LC_ALL": "C.UTF-8", "GIT_CONFIG_GLOBAL": str(GIT_CONFIG_GLOBAL)}, timeout=30)
|
|
||||||
if probe.returncode != 0 or probe.stdout.strip() != governed["commit_oid"]:
|
|
||||||
raise WorkerError("RECOVERY_HEAD_UNVERIFIED", phase="recovery")
|
|
||||||
result = state.get("result")
|
result = state.get("result")
|
||||||
if not isinstance(result, Mapping):
|
if not isinstance(result, Mapping):
|
||||||
result = {"status": "SUCCEEDED", "verification_state": "VERIFIED_BY_WORKER_PENDING_SERVER_REVIEW", "task_id": task_id, "execution_digest": envelope.digest, "governed": dict(governed), "recovered_without_duplicate_commit": True, "direct_main_write": False, "production_write": False, "force_push": False, "protected_ref_bypass": False}
|
result = {"status": "SUCCEEDED", "verification_state": "VERIFIED_BY_WORKER_PENDING_SERVER_REVIEW", "task_id": task_id, "execution_digest": envelope.digest, "governed": dict(governed), "recovered_without_duplicate_commit": True, "direct_main_write": False, "production_write": False, "force_push": False, "protected_ref_bypass": False}
|
||||||
@@ -1353,7 +836,7 @@ class WorkerSupervisor:
|
|||||||
else:
|
else:
|
||||||
run_root = new_run_root()
|
run_root = new_run_root()
|
||||||
try:
|
try:
|
||||||
worktree = prepare_worktree(run_root, envelope)
|
worktree = prepare_worktree(run_root, envelope.base_oid)
|
||||||
state.update({"phase": "workspace_prepared", "execution_digest": envelope.digest, "attempt": claim.get("attempt")})
|
state.update({"phase": "workspace_prepared", "execution_digest": envelope.digest, "attempt": claim.get("attempt")})
|
||||||
save_task_state(task_id, state)
|
save_task_state(task_id, state)
|
||||||
adapter = adapter_for(envelope.adapter)
|
adapter = adapter_for(envelope.adapter)
|
||||||
@@ -1361,10 +844,6 @@ class WorkerSupervisor:
|
|||||||
state.update({"phase": "verification_passed", "execution": execution})
|
state.update({"phase": "verification_passed", "execution": execution})
|
||||||
save_task_state(task_id, state)
|
save_task_state(task_id, state)
|
||||||
heartbeat.ensure()
|
heartbeat.ensure()
|
||||||
if envelope.action == "source_capture":
|
|
||||||
result = build_source_capture_result(task_id, envelope, execution)
|
|
||||||
state.update({"phase": "source_capture_done", "result": result})
|
|
||||||
else:
|
|
||||||
heartbeat.set_stage("committing")
|
heartbeat.set_stage("committing")
|
||||||
governed = governed_commit(envelope, worktree, self.bridge, heartbeat)
|
governed = governed_commit(envelope, worktree, self.bridge, heartbeat)
|
||||||
result = build_result(task_id, envelope, execution, governed)
|
result = build_result(task_id, envelope, execution, governed)
|
||||||
@@ -1382,10 +861,6 @@ class WorkerSupervisor:
|
|||||||
audit("TASK_RESULT_SUBMITTED", task_id=task_id, api_status=status, result_idempotency_key=result_key, commit_oid=result.get("governed", {}).get("commit_oid"))
|
audit("TASK_RESULT_SUBMITTED", task_id=task_id, api_status=status, result_idempotency_key=result_key, commit_oid=result.get("governed", {}).get("commit_oid"))
|
||||||
except WorkerError as exc:
|
except WorkerError as exc:
|
||||||
audit("TASK_FAILED", task_id=task_id, error=exc.code, phase=exc.phase, details=exc.details)
|
audit("TASK_FAILED", task_id=task_id, error=exc.code, phase=exc.phase, details=exc.details)
|
||||||
if exc.code == "RECONCILIATION_REQUIRED":
|
|
||||||
save_task_state(task_id, {"task_id": task_id, "execution_digest": envelope.digest, "phase": "reconciliation_required", "attempt": claim.get("attempt"), "job_id": envelope.job_id, "details": exc.details})
|
|
||||||
audit("TASK_RECONCILIATION_REQUIRED", task_id=task_id, job_id=envelope.job_id, details=exc.details)
|
|
||||||
return
|
|
||||||
failure_key = f"{task_id}-attempt-{claim.get('attempt', 0)}-failure"
|
failure_key = f"{task_id}-attempt-{claim.get('attempt', 0)}-failure"
|
||||||
failure = {
|
failure = {
|
||||||
"status": "FAILED",
|
"status": "FAILED",
|
||||||
@@ -1416,22 +891,15 @@ class WorkerSupervisor:
|
|||||||
except WorkerError as exc:
|
except WorkerError as exc:
|
||||||
audit("TASK_POLL_FAILED", error=exc.code, phase=exc.phase, details=exc.details)
|
audit("TASK_POLL_FAILED", error=exc.code, phase=exc.phase, details=exc.details)
|
||||||
return 10
|
return 10
|
||||||
dynamic_tasks: list[tuple[dict[str, Any], ExecutionEnvelope]] = []
|
dynamic_tasks: list[dict[str, Any]] = []
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
try:
|
metadata = task.get("metadata")
|
||||||
envelope, source = self.resolve_envelope(task)
|
execution = metadata.get("execution") if isinstance(metadata, Mapping) else None
|
||||||
except WorkerError as exc:
|
if not isinstance(execution, Mapping) or execution.get("schema") != "aios.online-agent.execution.v2":
|
||||||
event = "TASK_SKIPPED_ENVELOPE_INVALID"
|
audit("TASK_SKIPPED_NON_DYNAMIC", task_id=task.get("task_id"), reason="DYNAMIC_EXECUTION_PAYLOAD_REQUIRED")
|
||||||
if exc.code in {"TASK_SKIPPED_PROJECTION_MISSING", "TASK_SKIPPED_POLICY_DENY"}:
|
|
||||||
event = exc.code
|
|
||||||
elif exc.code == "CAPABILITY_MISMATCH_DENY":
|
|
||||||
event = "TASK_SKIPPED_CAPABILITY_MISMATCH"
|
|
||||||
audit(event, task_id=task.get("task_id"), reason=exc.code, phase=exc.phase, details=exc.details)
|
|
||||||
continue
|
continue
|
||||||
audit("TASK_VISIBLE_TO_WORKER_CLAIMABLE", task_id=task.get("task_id"), envelope_source=source, action=envelope.action)
|
dynamic_tasks.append(task)
|
||||||
dynamic_tasks.append((task, envelope))
|
for task in dynamic_tasks:
|
||||||
dynamic_tasks.sort(key=_claim_order)
|
|
||||||
for task, envelope in dynamic_tasks:
|
|
||||||
task_id = str(task.get("task_id") or "")
|
task_id = str(task.get("task_id") or "")
|
||||||
status, claim = self.agent.claim(task_id)
|
status, claim = self.agent.claim(task_id)
|
||||||
if status == 409:
|
if status == 409:
|
||||||
@@ -1440,13 +908,13 @@ class WorkerSupervisor:
|
|||||||
if status != 200:
|
if status != 200:
|
||||||
audit("TASK_CLAIM_FAILED", task_id=task_id, api_status=status, error=claim.get("error"))
|
audit("TASK_CLAIM_FAILED", task_id=task_id, api_status=status, error=claim.get("error"))
|
||||||
continue
|
continue
|
||||||
self.process_claim(task, claim, envelope)
|
self.process_claim(task, claim)
|
||||||
return 0
|
return 0
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def run_forever(self) -> int:
|
def run_forever(self) -> int:
|
||||||
ensure_roots()
|
ensure_roots()
|
||||||
audit("WORKER_STARTED", task_agnostic=True, api_base=API_BASE, bridge_base=BRIDGE_BASE, adapter_names=["isolated-deterministic", "source_capture", "codex", "hermes"], runtime_only_credentials=True, isolated_executor=True)
|
audit("WORKER_STARTED", task_agnostic=True, api_base=API_BASE, bridge_base=BRIDGE_BASE, adapter_names=["deterministic", "codex", "hermes"], runtime_only_credentials=True)
|
||||||
while True:
|
while True:
|
||||||
self.once()
|
self.once()
|
||||||
time.sleep(POLL_SECONDS)
|
time.sleep(POLL_SECONDS)
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Register or recover the generic Online Agent using runtime-only secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
BASE = os.environ.get("AIOS_AGENT_TASK_API_URL", "http://127.0.0.1:18940").rstrip("/")
|
||||||
|
AGENT_ID = os.environ.get("AIOS_AGENT_ID", "online-engineering-worker-cn-network-mkt-generic-20260815")
|
||||||
|
AGENT_TYPE = os.environ.get("AIOS_AGENT_TYPE", "trusted-online-engineering-worker")
|
||||||
|
AGENT_VERSION = os.environ.get("AIOS_AGENT_VERSION", "2026-08-15.3-generic")
|
||||||
|
PROJECTION_PROVIDER = "BridgeV2RuntimeCredentialProjection"
|
||||||
|
STATE_DIR = Path(os.environ.get("AIOS_WORKER_REGISTRATION_STATE", "/var/lib/aios-online-agent-worker-20260815/registration"))
|
||||||
|
CREDENTIAL_PATH = Path(os.environ.get("AIOS_WORKER_ENCRYPTED_CREDENTIAL", "/etc/credstore.encrypted/aios-online-agent-worker-20260815/agent_token"))
|
||||||
|
IDENTITY_PATH = STATE_DIR / "identity.json"
|
||||||
|
AUDIT_PATH = STATE_DIR / "events.jsonl"
|
||||||
|
CAPABILITIES = [
|
||||||
|
"online_agent_task_api", "task.poll", "task.claim", "task.heartbeat", "task.result",
|
||||||
|
"engineering.consumer", "engineering.execute", "workspace.worktree", "git.read", "git.diff",
|
||||||
|
"git.test", "governed.write.branch", "governed.write.file",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def audit(event_type: str, **fields: Any) -> None:
|
||||||
|
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||||
|
record = {"event_type": event_type, "created_at": now(), "agent_id": AGENT_ID, "credential_material_exposed": False, **fields}
|
||||||
|
with AUDIT_PATH.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n")
|
||||||
|
os.chmod(AUDIT_PATH, 0o640)
|
||||||
|
print(json.dumps(record, ensure_ascii=False, sort_keys=True), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def api_call(method: str, route: str, token: str, body: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||||
|
payload = None if body is None else json.dumps(body, ensure_ascii=False, 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=15) as response:
|
||||||
|
value = json.loads(response.read().decode("utf-8"))
|
||||||
|
return response.status, value if isinstance(value, dict) else {}
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
try:
|
||||||
|
value = json.loads(exc.read(2048).decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
value = {"error": "HTTP_ERROR"}
|
||||||
|
return exc.code, value if isinstance(value, dict) else {"error": "HTTP_ERROR"}
|
||||||
|
except (urllib.error.URLError, TimeoutError):
|
||||||
|
return 599, {"error": "AGENT_TASK_API_UNAVAILABLE"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_identity() -> dict[str, Any] | None:
|
||||||
|
if not IDENTITY_PATH.is_file() or IDENTITY_PATH.is_symlink():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = json.loads(IDENTITY_PATH.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return value if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def identity_matches(value: dict[str, Any] | None) -> bool:
|
||||||
|
return bool(value and value.get("agent_id") == AGENT_ID and value.get("agent_type") == AGENT_TYPE and value.get("agent_version") == AGENT_VERSION and value.get("credential_projection_provider") == PROJECTION_PROVIDER and value.get("task_scope") == "dynamic")
|
||||||
|
|
||||||
|
|
||||||
|
def encrypted_credential_is_valid() -> bool:
|
||||||
|
if not CREDENTIAL_PATH.is_file() or CREDENTIAL_PATH.is_symlink():
|
||||||
|
return False
|
||||||
|
result = subprocess.run(["systemd-creds", "decrypt", "--with-key=host", str(CREDENTIAL_PATH), "/dev/null"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=15)
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def write_identity() -> None:
|
||||||
|
value = {"agent_id": AGENT_ID, "agent_type": AGENT_TYPE, "agent_version": AGENT_VERSION, "task_scope": "dynamic", "credential_projection_provider": PROJECTION_PROVIDER, "credential_delivery": "systemd-LoadCredentialEncrypted", "task_api_url": BASE, "task_api_schema": "aios.agent-task.v1", "execution_backend": "generic-online-agent-worker-v2", "runtime_only_secret_delivery": True, "production_write": False, "main_write": False, "force_push": False, "tag_write": False, "baseline_write": False, "writer_lease": True, "created_at": now()}
|
||||||
|
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix="identity.", dir=str(STATE_DIR))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(value, handle, ensure_ascii=False, sort_keys=True)
|
||||||
|
handle.write("\n")
|
||||||
|
os.chmod(temporary, 0o640)
|
||||||
|
os.replace(temporary, IDENTITY_PATH)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_agent_token(agent_token: str) -> None:
|
||||||
|
CREDENTIAL_PATH.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
fd, temporary = tempfile.mkstemp(prefix=".online-agent-token.", dir=str(CREDENTIAL_PATH.parent))
|
||||||
|
os.close(fd)
|
||||||
|
os.unlink(temporary)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(["systemd-creds", "encrypt", "--with-key=host", "--name=agent_token", "/dev/stdin", temporary], input=(agent_token + "\n").encode("utf-8"), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, timeout=20)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError("AGENT_CREDENTIAL_ENCRYPT_FAILED")
|
||||||
|
os.chmod(temporary, 0o600)
|
||||||
|
os.replace(temporary, CREDENTIAL_PATH)
|
||||||
|
os.chmod(CREDENTIAL_PATH, 0o600)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temporary):
|
||||||
|
os.unlink(temporary)
|
||||||
|
|
||||||
|
|
||||||
|
def register_or_recover() -> str:
|
||||||
|
STATE_DIR.mkdir(mode=0o750, parents=True, exist_ok=True)
|
||||||
|
existing = read_identity()
|
||||||
|
if CREDENTIAL_PATH.exists():
|
||||||
|
if not identity_matches(existing):
|
||||||
|
raise RuntimeError("AGENT_IDENTITY_BINDING_CONFLICT")
|
||||||
|
if not encrypted_credential_is_valid():
|
||||||
|
raise RuntimeError("AGENT_ENCRYPTED_CREDENTIAL_INVALID")
|
||||||
|
audit("AGENT_RECOVERED", credential_delivery="systemd-LoadCredentialEncrypted", credential_projection_provider=PROJECTION_PROVIDER, register_or_recover=True)
|
||||||
|
return "RECOVERED"
|
||||||
|
if existing is not None:
|
||||||
|
raise RuntimeError("AGENT_CREDENTIAL_MISSING_AFTER_PRIOR_REGISTRATION")
|
||||||
|
credential_directory = Path(os.environ.get("CREDENTIALS_DIRECTORY", ""))
|
||||||
|
control_path = credential_directory / "bridge_control_token"
|
||||||
|
if not control_path.is_file():
|
||||||
|
raise RuntimeError("BRIDGE_RUNTIME_CONTROL_CREDENTIAL_UNAVAILABLE")
|
||||||
|
control_token = control_path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(control_token) < 32:
|
||||||
|
raise RuntimeError("BRIDGE_RUNTIME_CONTROL_CREDENTIAL_INVALID")
|
||||||
|
status, response = api_call("POST", "/v1/agents/register", control_token, {"agent_id": AGENT_ID, "agent_type": AGENT_TYPE, "agent_version": AGENT_VERSION, "capabilities": CAPABILITIES, "metadata": {"task_scope": "dynamic", "credential_projection_provider": PROJECTION_PROVIDER, "runtime_only_secret_delivery": True, "task_api_schema": "aios.agent-task.v1", "execution_backend": "generic-online-agent-worker-v2", "production_write": False, "main_write": False, "force_push": False, "tag_write": False, "baseline_write": False, "writer_lease": True}})
|
||||||
|
if status == 409 and response.get("error") == "AGENT_ALREADY_EXISTS":
|
||||||
|
raise RuntimeError("AGENT_ALREADY_EXISTS_RECOVERY_CREDENTIAL_UNAVAILABLE")
|
||||||
|
if status != 201:
|
||||||
|
raise RuntimeError("AGENT_REGISTER_FAILED_" + str(response.get("error") or status))
|
||||||
|
agent_token = response.get("agent_token")
|
||||||
|
if not isinstance(agent_token, str) or len(agent_token) < 24:
|
||||||
|
raise RuntimeError("AGENT_TOKEN_NOT_RETURNED")
|
||||||
|
encrypt_agent_token(agent_token)
|
||||||
|
write_identity()
|
||||||
|
audit("AGENT_REGISTERED", credential_delivery="systemd-LoadCredentialEncrypted", credential_projection_provider=PROJECTION_PROVIDER, register_or_recover=True)
|
||||||
|
return "REGISTERED"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
raise SystemExit(0 if register_or_recover() in {"REGISTERED", "RECOVERED"} else 20)
|
||||||
|
except Exception as exc:
|
||||||
|
audit("AGENT_REGISTRATION_FAILED", error=type(exc).__name__)
|
||||||
|
raise SystemExit(20)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from aios_online_agent_worker.worker import ExecutionEnvelope, WorkerError, sanitize # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
BASE = "a" * 40
|
||||||
|
|
||||||
|
|
||||||
|
def payload(task_id: str, branch: str, *, base_oid: str = BASE) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"metadata": {
|
||||||
|
"execution": {
|
||||||
|
"schema": "aios.online-agent.execution.v2",
|
||||||
|
"repository": "aios/aios",
|
||||||
|
"base_oid": base_oid,
|
||||||
|
"target_branch": branch,
|
||||||
|
"adapter": "deterministic",
|
||||||
|
"workspace": {
|
||||||
|
"authority_git_dir": str(Path(os.environ.get("AIOS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))),
|
||||||
|
"read_only_repository": True,
|
||||||
|
"cleanup_policy": "always",
|
||||||
|
},
|
||||||
|
"change_set": [
|
||||||
|
{
|
||||||
|
"operation": "write_file",
|
||||||
|
"path": "tests/generated_marker.py",
|
||||||
|
"content": "def test_marker():\n assert True\n",
|
||||||
|
"expected_sha256": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verification": [
|
||||||
|
{"label": "compile", "argv": ["/usr/bin/python3", "-m", "py_compile", "tests/generated_marker.py"], "cwd": ".", "timeout_seconds": 60}
|
||||||
|
],
|
||||||
|
"governance": {
|
||||||
|
"task_id": f"{task_id}-writer",
|
||||||
|
"idempotency_key": f"{task_id}-governed-v1",
|
||||||
|
"commit_idempotency_key": f"{task_id}-commit-v1",
|
||||||
|
"allowed_files": ["tests/generated_marker.py"],
|
||||||
|
"main_write": False,
|
||||||
|
"force_push": False,
|
||||||
|
"production_write": False,
|
||||||
|
"tag_write": False,
|
||||||
|
"baseline_write": False,
|
||||||
|
"protected_ref_bypass": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_payload_is_not_bound_to_task_or_base() -> None:
|
||||||
|
first = ExecutionEnvelope.from_task(payload("task-alpha-20260815", "agent/alpha-20260815"))
|
||||||
|
second = ExecutionEnvelope.from_task(payload("task-beta-20260815", "agent/beta-20260815", base_oid="b" * 40))
|
||||||
|
assert first.task_id != second.task_id
|
||||||
|
assert first.base_oid != second.base_oid
|
||||||
|
assert first.target_branch != second.target_branch
|
||||||
|
|
||||||
|
|
||||||
|
def test_protected_branch_is_rejected() -> None:
|
||||||
|
try:
|
||||||
|
ExecutionEnvelope.from_task(payload("task-main-20260815", "main"))
|
||||||
|
except WorkerError as exc:
|
||||||
|
assert exc.code == "PROTECTED_BRANCH_REJECTED"
|
||||||
|
else:
|
||||||
|
raise AssertionError("protected branch accepted")
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_payload_does_not_persist_secret_values() -> None:
|
||||||
|
value = sanitize({"agent_token": "agt-secret", "nested": {"authorization": "Bearer secret"}})
|
||||||
|
assert value == {"agent_token": "[REDACTED]", "nested": {"authorization": "[REDACTED]"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_execution_digest_is_stable() -> None:
|
||||||
|
value = payload("task-digest-20260815", "agent/digest-20260815")
|
||||||
|
assert ExecutionEnvelope.from_task(value).digest == ExecutionEnvelope.from_task(value).digest
|
||||||
@@ -45,9 +45,6 @@ class ShadowClientRBACPublicInterfaceTests(unittest.TestCase):
|
|||||||
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
||||||
execution_enabled=False,
|
execution_enabled=False,
|
||||||
identity_capabilities={"shadow-client": set(capabilities)},
|
identity_capabilities={"shadow-client": set(capabilities)},
|
||||||
identity_actor_contexts={
|
|
||||||
"shadow-client": {"scope_type": "system"}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
service = self.TaskPlaneService(store, policy=policy)
|
service = self.TaskPlaneService(store, policy=policy)
|
||||||
|
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from http.server import ThreadingHTTPServer
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT = os.getcwd()
|
|
||||||
if os.path.join(ROOT, "active-runtime") not in sys.path:
|
|
||||||
sys.path.append(os.path.join(ROOT, "active-runtime"))
|
|
||||||
|
|
||||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
|
||||||
from app.task_plane.errors import ForbiddenError, NotFoundError, ValidationError
|
|
||||||
from app.task_plane.models import TaskPlan
|
|
||||||
from app.task_plane.policy import PolicyEngine
|
|
||||||
from app.task_plane.store import TaskPlaneStore
|
|
||||||
|
|
||||||
|
|
||||||
def _policy() -> PolicyEngine:
|
|
||||||
return PolicyEngine(
|
|
||||||
identity_capabilities={
|
|
||||||
"actor-a": {"task.creator", "task.read"},
|
|
||||||
"actor-b": {"task.creator", "task.read"},
|
|
||||||
"reader-a": {"task.read"},
|
|
||||||
"reader-b": {"task.read"},
|
|
||||||
"owner": {"task.creator", "task.read", "task.read.all"},
|
|
||||||
"unbound": {"task.creator", "task.read"},
|
|
||||||
},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"actor-a": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
"reader-a": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
"actor-b": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
"reader-b": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
"owner": {"scope_type": "system"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _service() -> TaskPlaneService:
|
|
||||||
return TaskPlaneService(TaskPlaneStore(":memory:"), policy=_policy())
|
|
||||||
|
|
||||||
|
|
||||||
def test_taskplan_scope_fields_round_trip_as_first_class_values() -> None:
|
|
||||||
plan = TaskPlan.from_dict(
|
|
||||||
{
|
|
||||||
"task_id": "task-scope-roundtrip",
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload = plan.to_dict()
|
|
||||||
assert TaskPlan.from_dict(payload).to_dict() == payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_tenant_scope_requires_tenant_id_and_system_scope_does_not() -> None:
|
|
||||||
with pytest.raises(ValidationError) as exc:
|
|
||||||
TaskPlan.from_dict({"task_id": "task-missing-tenant", "scope_type": "tenant"})
|
|
||||||
assert exc.value.error_code == "TASK_SCOPE_TENANT_ID_REQUIRED"
|
|
||||||
system = TaskPlan.from_dict(
|
|
||||||
{"task_id": "task-system", "scope_type": "system", "run_id": "system-run"}
|
|
||||||
)
|
|
||||||
assert system.tenant_id is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_ACTOR_BINDING_AUTHZ_MERGE_uses_server_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
created = service.create_task(
|
|
||||||
{"task_id": "task-a", "task_type": "code.change"},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
|
||||||
assert created["plan"]["user_id"] == "user-a"
|
|
||||||
assert created["plan"]["project_id"] == "project-a"
|
|
||||||
declared = service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-a-declared",
|
|
||||||
"tenant_id": "tenant-a",
|
|
||||||
"user_id": "user-a",
|
|
||||||
"project_id": "project-a",
|
|
||||||
"conversation_id": "conversation-a",
|
|
||||||
"run_id": "run-a",
|
|
||||||
},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert declared["plan"]["scope_type"] == "tenant"
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASK_SCOPE_MISMATCH_DENY_rejects_forged_client_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
with pytest.raises(ForbiddenError) as exc:
|
|
||||||
service.create_task(
|
|
||||||
{
|
|
||||||
"task_id": "task-forged",
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-b",
|
|
||||||
"user_id": "user-b",
|
|
||||||
"project_id": "project-b",
|
|
||||||
},
|
|
||||||
actor_identity="actor-a",
|
|
||||||
)
|
|
||||||
assert exc.value.error_code == "TASK_SCOPE_MISMATCH_DENY"
|
|
||||||
|
|
||||||
|
|
||||||
def test_public_create_fails_closed_without_server_actor_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
with pytest.raises(ForbiddenError) as exc:
|
|
||||||
service.create_task(
|
|
||||||
{"task_id": "task-unbound"}, actor_identity="unbound"
|
|
||||||
)
|
|
||||||
assert exc.value.error_code == "TASK_ACTOR_SCOPE_UNAVAILABLE"
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASKPLANE_LIST_AUTHZ_DENY_and_TASK_OWNERSHIP_FILTER() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
|
||||||
assert [item["task_id"] for item in service.list_tasks("reader-a")] == ["task-a"]
|
|
||||||
assert [item["task_id"] for item in service.list_tasks("reader-b")] == ["task-b"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_TASKPLANE_GET_AUTHZ_DENY_does_not_disclose_cross_tenant_task() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
service.get_task("task-a", "reader-b")
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_authentication_drives_server_scope_and_cross_tenant_404() -> None:
|
|
||||||
service = _service()
|
|
||||||
|
|
||||||
class Handler(TaskPlaneRequestHandler):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Handler.service = service
|
|
||||||
Handler.authenticator = lambda token: {
|
|
||||||
"token-a": "actor-a",
|
|
||||||
"token-b": "reader-b",
|
|
||||||
}.get(token)
|
|
||||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
||||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
thread.start()
|
|
||||||
base = f"http://127.0.0.1:{server.server_port}"
|
|
||||||
try:
|
|
||||||
request = urllib.request.Request(
|
|
||||||
base + "/tasks",
|
|
||||||
data=json.dumps({"task_id": "task-http-a"}).encode(),
|
|
||||||
headers={
|
|
||||||
"Authorization": "Bearer token-a",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
with urllib.request.urlopen(request, timeout=3) as response:
|
|
||||||
created = json.loads(response.read())
|
|
||||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
|
||||||
|
|
||||||
denied = urllib.request.Request(
|
|
||||||
base + "/tasks/task-http-a",
|
|
||||||
headers={"Authorization": "Bearer token-b"},
|
|
||||||
)
|
|
||||||
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
||||||
urllib.request.urlopen(denied, timeout=3)
|
|
||||||
assert exc.value.code == 404
|
|
||||||
finally:
|
|
||||||
server.shutdown()
|
|
||||||
server.server_close()
|
|
||||||
thread.join(timeout=3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_mutating_endpoint_is_scoped_before_cancel() -> None:
|
|
||||||
policy = PolicyEngine(
|
|
||||||
execution_enabled=True,
|
|
||||||
identity_capabilities={
|
|
||||||
"actor-a": {"task.creator", "task.read"},
|
|
||||||
"actor-b": {"task.cancel"},
|
|
||||||
},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"actor-a": {"scope_type": "tenant", "tenant_id": "tenant-a"},
|
|
||||||
"actor-b": {"scope_type": "tenant", "tenant_id": "tenant-b"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
service.cancel("task-a", {"actor_identity": "actor-b"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_privileged_server_actor_can_cross_scope() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
|
||||||
assert {item["task_id"] for item in service.list_tasks("owner")} == {
|
|
||||||
"task-a",
|
|
||||||
"task-b",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_scope_sql_parameters_do_not_interpolate_tenant_values() -> None:
|
|
||||||
service = _service()
|
|
||||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
|
||||||
policy = PolicyEngine(
|
|
||||||
identity_capabilities={"attacker": {"task.read"}},
|
|
||||||
identity_actor_contexts={
|
|
||||||
"attacker": {
|
|
||||||
"scope_type": "tenant",
|
|
||||||
"tenant_id": "tenant-a' OR 1=1 --",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
attacker_service = TaskPlaneService(service.store, policy=policy)
|
|
||||||
assert attacker_service.list_tasks("attacker") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_existing_sqlite_rows_migrate_to_legacy_without_tenant_guess(tmp_path) -> None:
|
|
||||||
path = tmp_path / "legacy.sqlite3"
|
|
||||||
connection = sqlite3.connect(path)
|
|
||||||
connection.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE tasks (
|
|
||||||
task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, status TEXT NOT NULL,
|
|
||||||
task_json TEXT NOT NULL, task_type TEXT NOT NULL, root_task_id TEXT NOT NULL,
|
|
||||||
parent_task_id TEXT, correlation_id TEXT, idempotency_key TEXT UNIQUE,
|
|
||||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, failure_reason TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
connection.execute(
|
|
||||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
(
|
|
||||||
"task-legacy",
|
|
||||||
1,
|
|
||||||
"CREATED",
|
|
||||||
json.dumps({"task_id": "task-legacy"}),
|
|
||||||
"code.change",
|
|
||||||
"task-legacy",
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
"legacy-key",
|
|
||||||
1.0,
|
|
||||||
1.0,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection.commit()
|
|
||||||
connection.close()
|
|
||||||
|
|
||||||
store = TaskPlaneStore(str(path))
|
|
||||||
task = store.get_task("task-legacy")
|
|
||||||
assert task.task_plan.scope_type == "legacy"
|
|
||||||
assert task.task_plan.tenant_id is None
|
|
||||||
replay = store.create_task(
|
|
||||||
TaskPlan.from_dict({"task_id": "task-legacy"}),
|
|
||||||
idempotency_key="legacy-key",
|
|
||||||
)
|
|
||||||
assert replay.task_id == "task-legacy"
|
|
||||||
assert tuple(
|
|
||||||
store.conn.execute(
|
|
||||||
"SELECT scope_type,tenant_id FROM tasks WHERE task_id='task-legacy'"
|
|
||||||
).fetchone()
|
|
||||||
) == ("legacy", None)
|
|
||||||
tenant_context = _policy().actor_context("reader-a")
|
|
||||||
with pytest.raises(NotFoundError):
|
|
||||||
store.get_task("task-legacy", tenant_context)
|
|
||||||
store.close()
|
|
||||||
@@ -20,12 +20,3 @@ def test_unified_event_flow():
|
|||||||
|
|
||||||
dashboard = AgentEventDashboard(bus).overview()
|
dashboard = AgentEventDashboard(bus).overview()
|
||||||
assert dashboard["audit"]["enabled"] is True
|
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
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import hashlib, json, sqlite3, sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0,str(ROOT/"active-runtime/app"))
|
|
||||||
from aios_agent_task_retry_extension import RetryExtensionRequest, RetryExtensionStore, RetryExtensionError
|
|
||||||
|
|
||||||
AUTHORIZATION_ID="owner-scheme-a-20260817"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _authorization_id(monkeypatch):
|
|
||||||
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", AUTHORIZATION_ID)
|
|
||||||
|
|
||||||
|
|
||||||
def canon(v):
|
|
||||||
return hashlib.sha256(json.dumps(v,sort_keys=True,separators=(",",":")).encode()).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def db(tmp_path):
|
|
||||||
p=tmp_path/"state.sqlite3"
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
c.executescript("""
|
|
||||||
CREATE TABLE tasks(
|
|
||||||
task_id TEXT PRIMARY KEY,status TEXT,attempt INTEGER,max_attempts INTEGER,revision INTEGER,
|
|
||||||
claimed_by TEXT,lease_token_hash TEXT,lease_expires_at TEXT,verification_json TEXT,result_json TEXT
|
|
||||||
);
|
|
||||||
CREATE TABLE task_events(
|
|
||||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,task_id TEXT,event_type TEXT,agent_id TEXT,
|
|
||||||
payload_json TEXT,created_at TEXT
|
|
||||||
);
|
|
||||||
""")
|
|
||||||
verification={"decision":"NEEDS_REWORK","reason":"BASE_OID_BINDING_INVALID"}
|
|
||||||
c.execute("INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
||||||
("task-a","FAILED",10,10,41,None,None,None,json.dumps(verification),json.dumps({"status":"FAILED"})))
|
|
||||||
c.commit(); c.close()
|
|
||||||
return p,verification
|
|
||||||
|
|
||||||
|
|
||||||
def request(v):
|
|
||||||
return RetryExtensionRequest.from_mapping({
|
|
||||||
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
|
||||||
"next_max_attempts":11,"previous_verification_sha256":canon(v),
|
|
||||||
"authorization_id":AUTHORIZATION_ID,"idempotency_key":"retry-task-a-attempt11",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_cas_and_audit(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
out=RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
assert out["status"]=="QUEUED" and out["max_attempts"]==11 and out["revision"]==42
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute("select status,attempt,max_attempts,revision from tasks").fetchone()==("QUEUED",10,11,42)
|
|
||||||
event=c.execute("select event_type,payload_json from task_events").fetchone()
|
|
||||||
assert event[0]=="TASK_RETRY_EXTENSION_AUTHORIZED"
|
|
||||||
assert json.loads(event[1])["authorization_id"]=="owner-scheme-a-20260817"
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_idempotent_replay(tmp_path):
|
|
||||||
p,v=db(tmp_path); store=RetryExtensionStore(p); req=request(v)
|
|
||||||
first=store.extend("task-a",req); second=store.extend("task-a",req)
|
|
||||||
assert first["idempotent"] is False and second["idempotent"] is True
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute("select count(*) from task_retry_extensions").fetchone()[0]==1
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_normalizes_stale_terminal_claim_without_active_lease(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
c.execute("update tasks set claimed_by='previous-worker'")
|
|
||||||
c.commit(); c.close()
|
|
||||||
out=RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
assert out["status"]=="QUEUED"
|
|
||||||
c=sqlite3.connect(p)
|
|
||||||
assert c.execute(
|
|
||||||
"select claimed_by,lease_token_hash,lease_expires_at from tasks"
|
|
||||||
).fetchone()==(None,None,None)
|
|
||||||
payload=json.loads(c.execute("select payload_json from task_events").fetchone()[0])
|
|
||||||
assert payload["previous_claimed_by"]=="previous-worker"
|
|
||||||
assert payload["stale_terminal_claim_normalized"] is True
|
|
||||||
assert c.execute("select count(*) from task_events").fetchone()[0]==1
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_verification_drift_denied(tmp_path):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
bad=request({"decision":"NEEDS_REWORK","reason":"other"})
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_VERIFICATION_DRIFT"):
|
|
||||||
RetryExtensionStore(p).extend("task-a",bad)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_authorization_id_mismatch_rejected(tmp_path, monkeypatch):
|
|
||||||
p,v=db(tmp_path)
|
|
||||||
monkeypatch.setenv("AIOS_AGENT_TASK_AUTHORIZATION_ID", "other-owner-id")
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_AUTHORIZATION_ID_MISMATCH"):
|
|
||||||
RetryExtensionStore(p).extend("task-a",request(v))
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_bounded_one_step():
|
|
||||||
v={"decision":"NEEDS_REWORK"}
|
|
||||||
data={
|
|
||||||
"expected_status":"FAILED","expected_revision":41,"expected_attempt":10,"expected_max_attempts":10,
|
|
||||||
"next_max_attempts":12,"previous_verification_sha256":canon(v),
|
|
||||||
"authorization_id":"owner-scheme-a-20260817","idempotency_key":"retry-task-a-attempt11",
|
|
||||||
}
|
|
||||||
with pytest.raises(RetryExtensionError,match="RETRY_EXTENSION_MUST_BE_BOUNDED_ONE_STEP"):
|
|
||||||
RetryExtensionRequest.from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retry_extension_unit_is_unprivileged_and_requires_owner_binding():
|
|
||||||
unit=(ROOT/"deploy/systemd/aios-agent-task-retry-extension.service").read_text()
|
|
||||||
assert "User=aios-agent-task" in unit
|
|
||||||
assert "Group=aios-agent-task" in unit
|
|
||||||
assert "LoadCredential=agent_task_control_token:" in unit
|
|
||||||
assert "EnvironmentFile=/etc/aios-gitops/agent-task-retry-extension.env" in unit
|
|
||||||
assert "User=root" not in unit
|
|
||||||
@@ -2,7 +2,6 @@ import base64
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from aios_authority_git_executor import AuthorityGitExecutor
|
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_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
||||||
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
||||||
from aios_continuation_lease import WriterLeaseRequestV2
|
from aios_continuation_lease import WriterLeaseRequestV2
|
||||||
@@ -27,7 +26,6 @@ class Probe:
|
|||||||
|
|
||||||
def test_create_new_lease_is_exact_base_and_absent():
|
def test_create_new_lease_is_exact_base_and_absent():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
job_id="job-bridge-runtime-lineage-test",
|
|
||||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
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():
|
def test_create_new_lease_rejects_existing_target():
|
||||||
request = WriterLeaseRequestV2(
|
request = WriterLeaseRequestV2(
|
||||||
job_id="job-bridge-runtime-lineage-test",
|
|
||||||
task_id="task",
|
task_id="task",
|
||||||
repository="aios/aios",
|
repository="aios/aios",
|
||||||
branch_mode="CREATE_NEW",
|
branch_mode="CREATE_NEW",
|
||||||
@@ -77,8 +74,8 @@ def test_slash_branch_uses_paginated_collection():
|
|||||||
|
|
||||||
def test_http_route_contract_is_complete():
|
def test_http_route_contract_is_complete():
|
||||||
expected = {
|
expected = {
|
||||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task", "/v2/execution-envelope", "/v2/execution-plan",
|
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||||
"/v2/writer-lease", "/v2/writer-lease/bootstrap", "/v2/writer-lease/renew", "/v2/commit-file", "/v2/commit-files",
|
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||||
"/v2/tag/approval-request",
|
"/v2/tag/approval-request",
|
||||||
"/v2/tag/publish",
|
"/v2/tag/publish",
|
||||||
@@ -91,134 +88,3 @@ def test_http_route_contract_is_complete():
|
|||||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||||
|
|
||||||
assert set(ROUTES) == expected
|
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"]
|
|
||||||
|
|||||||
@@ -1,304 +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})
|
|
||||||
|
|
||||||
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,127 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime as dt
|
|
||||||
import pathlib
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
|
||||||
if str(APP) not in sys.path:
|
|
||||||
sys.path.insert(0, str(APP))
|
|
||||||
|
|
||||||
from message_handoff import (
|
|
||||||
ConfidentialityScopeDenied,
|
|
||||||
DeliveryContext,
|
|
||||||
DurableHandoffStore,
|
|
||||||
HandoffFabric,
|
|
||||||
IdempotencyConflict,
|
|
||||||
MessageEnvelope,
|
|
||||||
MessageExpired,
|
|
||||||
MessageScopeMismatch,
|
|
||||||
SequenceGap,
|
|
||||||
authorize_delivery,
|
|
||||||
bind_envelope_scope,
|
|
||||||
stable_event_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def actor(**overrides):
|
|
||||||
values = dict(
|
|
||||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
|
||||||
conversation_id="conversation-a", run_id="run-a",
|
|
||||||
capabilities=("task.read", "message.read"),
|
|
||||||
)
|
|
||||||
values.update(overrides)
|
|
||||||
return DeliveryContext(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def envelope(**overrides):
|
|
||||||
values = dict(
|
|
||||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
|
||||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
|
||||||
conversation_id="conversation-a", task_id="task-a", run_id="run-a",
|
|
||||||
sequence=1, idempotency_key="idem-1", capability_scope=("message.read",),
|
|
||||||
confidentiality_scope="private",
|
|
||||||
)
|
|
||||||
values.update(overrides)
|
|
||||||
return MessageEnvelope(**values)
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_TARGET_COLLISION_DENY():
|
|
||||||
a = stable_event_id(source="a", target="user-a", message_type="event", payload={"x": 1},
|
|
||||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
|
||||||
b = stable_event_id(source="a", target="user-b", message_type="event", payload={"x": 1},
|
|
||||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
|
||||||
assert a != b
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_SCOPE_MISMATCH_DENY():
|
|
||||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
|
||||||
bind_envelope_scope(envelope(tenant_id="tenant-b"), actor())
|
|
||||||
|
|
||||||
|
|
||||||
def test_HANDOFF_CROSS_TENANT_DENY(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
env = envelope()
|
|
||||||
store.put(env)
|
|
||||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
|
||||||
store.get(env.envelope_id, actor=actor(tenant_id="tenant-b"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE():
|
|
||||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
|
||||||
authorize_delivery(actor(project_id="project-b"), envelope(confidentiality_scope="project"))
|
|
||||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
|
||||||
authorize_delivery(actor(), envelope(capability_scope=("message.read", "secret.read")))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_IDEMPOTENCY_REPLAY(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
env = envelope()
|
|
||||||
assert store.put(env)[0] == "CREATED"
|
|
||||||
status, replay = store.put(env)
|
|
||||||
assert status == "REPLAY"
|
|
||||||
assert replay.envelope_id == env.envelope_id
|
|
||||||
with pytest.raises(IdempotencyConflict, match="MESSAGE_IDEMPOTENCY_CONFLICT"):
|
|
||||||
store.put(envelope(payload={"value": 2}))
|
|
||||||
|
|
||||||
|
|
||||||
def test_MESSAGE_SEQUENCE_GAP_DETECT(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
|
||||||
with pytest.raises(SequenceGap, match="MESSAGE_SEQUENCE_GAP_DETECT"):
|
|
||||||
store.put(envelope(sequence=3, idempotency_key="three"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_sequence_advances_and_scope_filters_pending(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
|
||||||
store.put(envelope(sequence=2, idempotency_key="two", payload={"value": 2}))
|
|
||||||
assert [item.sequence for item in store.pending(target="agent-b", actor=actor())] == [1, 2]
|
|
||||||
assert store.pending(target="agent-b", actor=actor(tenant_id="tenant-b")) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_expired_message_rejected(tmp_path):
|
|
||||||
expired = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1)).isoformat()
|
|
||||||
with pytest.raises(MessageExpired, match="MESSAGE_EXPIRED"):
|
|
||||||
DurableHandoffStore(tmp_path / "handoff.sqlite3").put(envelope(expires_at=expired))
|
|
||||||
|
|
||||||
|
|
||||||
def test_server_actor_binding_fills_scope_and_recomputes_identity():
|
|
||||||
public = MessageEnvelope(
|
|
||||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
|
||||||
sequence=1, idempotency_key="bind-1", confidentiality_scope="public",
|
|
||||||
)
|
|
||||||
bound = bind_envelope_scope(public, actor())
|
|
||||||
assert (bound.tenant_id, bound.user_id, bound.project_id, bound.run_id) == (
|
|
||||||
"tenant-a", "user-a", "project-a", "run-a"
|
|
||||||
)
|
|
||||||
assert bound.envelope_id != public.envelope_id
|
|
||||||
|
|
||||||
|
|
||||||
def test_handoff_fabric_publish_is_durable(tmp_path):
|
|
||||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
|
||||||
status, env = HandoffFabric(store).publish(envelope(), actor=actor())
|
|
||||||
assert status == "CREATED"
|
|
||||||
assert store.get(env.envelope_id, actor=actor()).payload == {"value": 1}
|
|
||||||
@@ -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) == ""
|
|
||||||
Reference in New Issue
Block a user