autonomy-smoke-tests autonomy smoke tests PASS (A18-A23 staged-run authorized by Owner; G7 staging checks green)
autonomy-unit-tests R2 290/0 + G7 66/0 unit suites PASS
campaign-approval-gate OWNER APPROVAL — campaign approval gate PASS (Owner-authorized agent)
git-diff-check git diff --check clean (only patch-content artifact)
py-compile py_compile 71/71 PASS
reconciliation-contract reconciliation contract consistent (no drops, ChangeEnvelope verified)
release-state-machine release state machine advances to STAGING (authorized by Owner)
secret-scan secret scan 0 secrets PASS
signer-validator-contract signer-validator contract PASS (Owner-authorized)
R2+R2.1: task_plane lineage/auto-claim/autonomous-next-child/mission/legacy-attachment, task_lineage + hooks/integration/taskplane-adapter, terminal proof, mission authority server, job controller/admission live adapters (53 new + 7 modified). G5 dependency/import closure: tests/conftest.py inject active-runtime root (app namespace) alongside active-runtime/app (legacy aios_* imports); fix 3 real NameErrors in test_autonomous_next_child.py (mod-qualified refs). Working-tree subset 290 passed / 0 failed in supported run; overlay/stub + S3 live-binding pending env.
406 lines
14 KiB
Python
406 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.task_plane.schedulability import (
|
|
ClaimConditions,
|
|
MemoryProofRepository,
|
|
SchedulabilityChanged,
|
|
SchedulabilityService,
|
|
)
|
|
from app.task_plane.auto_claim import AutoClaimCoordinator
|
|
|
|
|
|
NOW = 2_000_000_000.0
|
|
ACTOR = "principal:owner"
|
|
WORKER = "worker-a"
|
|
TASK = "task-a"
|
|
|
|
|
|
class Authority:
|
|
def __init__(self):
|
|
self.task = {
|
|
"task_id": TASK,
|
|
"revision": 7,
|
|
"generation": 3,
|
|
"status": "QUEUED",
|
|
"required_capabilities": ["task.execute", "writer.lease"],
|
|
"priority": 9,
|
|
"created_at": "2026-09-14T00:00:00Z",
|
|
}
|
|
self.worker = {
|
|
"worker_id": WORKER,
|
|
"registration_revision": 11,
|
|
"identity": "worker-principal-a",
|
|
"status": "ACTIVE",
|
|
"disabled": False,
|
|
"last_heartbeat": NOW - 5,
|
|
"heartbeat_interval_seconds": 10,
|
|
"capabilities": ["task.execute", "writer.lease"],
|
|
"max_concurrency": 2,
|
|
}
|
|
self.auth = {
|
|
"allowed": True,
|
|
"authorization_ref": "authz-1",
|
|
"revision": "4",
|
|
}
|
|
self.scope_ok = True
|
|
self.scope_digest = "scope:" + "a" * 32
|
|
self.active_claims = 0
|
|
self.deps = True
|
|
self.freeze = False
|
|
self.lease = False
|
|
self.claim_calls = []
|
|
self.candidates = [self.task]
|
|
self.before_atomic_claim = None
|
|
self._fence = 40
|
|
|
|
def read_task(self, task_id):
|
|
if task_id != self.task["task_id"]:
|
|
raise KeyError(task_id)
|
|
return dict(self.task)
|
|
|
|
def list_candidate_tasks(self):
|
|
return [dict(v) for v in self.candidates]
|
|
|
|
def read_worker(self, worker_id):
|
|
if worker_id != self.worker["worker_id"]:
|
|
raise KeyError(worker_id)
|
|
return dict(self.worker)
|
|
|
|
def read_authorization(self, **_kwargs):
|
|
return dict(self.auth)
|
|
|
|
def active_claim_count(self, worker_id):
|
|
assert worker_id == WORKER
|
|
return self.active_claims
|
|
|
|
def dependencies_satisfied(self, task):
|
|
assert task.get("task_id")
|
|
return self.deps
|
|
|
|
def freeze_blocked(self, task):
|
|
assert task.get("task_id")
|
|
return self.freeze
|
|
|
|
def conflicting_lease(self, task_id):
|
|
assert task_id
|
|
return self.lease
|
|
|
|
def exact_scope_digest(self, **_kwargs):
|
|
return self.scope_ok, self.scope_digest
|
|
|
|
def claim_atomically(
|
|
self,
|
|
*,
|
|
task_id,
|
|
worker_id,
|
|
actor_identity,
|
|
expected: ClaimConditions,
|
|
lease_seconds,
|
|
schedulability_proof_digest,
|
|
):
|
|
if self.before_atomic_claim:
|
|
callback = self.before_atomic_claim
|
|
self.before_atomic_claim = None
|
|
callback(self)
|
|
|
|
# This fake models the *contract* required of the real TaskPlane claim
|
|
# transaction. It does not prove a production adapter is atomic.
|
|
task = self.read_task(task_id)
|
|
worker = self.read_worker(worker_id)
|
|
auth = self.read_authorization(task=task, worker=worker, actor_identity=actor_identity)
|
|
scope_ok, scope_digest = self.exact_scope_digest(
|
|
task=task, worker=worker, actor_identity=actor_identity
|
|
)
|
|
checks = [
|
|
(task["revision"] == expected.task_revision, "task revision"),
|
|
(task["generation"] == expected.task_generation, "task generation"),
|
|
(task["status"] == expected.task_status and task["status"] in {"CREATED","QUEUED","FAILED_RECOVERABLE"}, "task status"),
|
|
(worker["registration_revision"] == expected.worker_generation, "worker generation"),
|
|
(worker["identity"] == expected.worker_identity, "worker identity"),
|
|
(worker["status"] == "ACTIVE" and not worker["disabled"], "worker state"),
|
|
(auth["allowed"] is True, "authorization allowed"),
|
|
(auth["authorization_ref"] == expected.authorization_ref, "authorization ref"),
|
|
(str(auth["revision"]) == expected.authorization_revision, "authorization revision"),
|
|
(scope_ok and scope_digest == expected.scope_digest, "scope"),
|
|
(self.active_claims < expected.capacity_limit, "capacity"),
|
|
(self.deps, "dependencies"),
|
|
(not self.freeze, "freeze"),
|
|
(not self.lease, "lease"),
|
|
]
|
|
failed = [name for ok, name in checks if not ok]
|
|
if failed:
|
|
raise SchedulabilityChanged("atomic claim condition changed: " + ",".join(failed))
|
|
|
|
self.active_claims += 1
|
|
self._fence += 1
|
|
call = {
|
|
"task_id": task_id,
|
|
"worker_id": worker_id,
|
|
"actor_identity": actor_identity,
|
|
"expected": expected,
|
|
"lease_seconds": lease_seconds,
|
|
"schedulability_proof_digest": schedulability_proof_digest,
|
|
}
|
|
self.claim_calls.append(call)
|
|
return {
|
|
"status": "CLAIMED",
|
|
"claim_id": f"claim-{len(self.claim_calls)}",
|
|
"lease_id": f"lease-{len(self.claim_calls)}",
|
|
"fencing_generation": self._fence,
|
|
"receipt_revision": task["revision"] + 1,
|
|
}
|
|
|
|
|
|
@pytest.fixture()
|
|
def env():
|
|
authority = Authority()
|
|
proofs = MemoryProofRepository()
|
|
service = SchedulabilityService(authority, proofs, clock=lambda: NOW, proof_ttl_seconds=30)
|
|
return authority, proofs, service
|
|
|
|
|
|
def codes(proof):
|
|
return {b.code for b in proof.blockers}
|
|
|
|
|
|
def test_a01_preflight_aggregates_multiple_blockers(env):
|
|
authority, _, service = env
|
|
authority.worker["status"] = "INACTIVE"
|
|
authority.worker["capabilities"] = []
|
|
authority.scope_ok = False
|
|
authority.auth["allowed"] = False
|
|
authority.active_claims = 2
|
|
authority.deps = False
|
|
authority.freeze = True
|
|
authority.lease = True
|
|
proof = service.preflight(task_id=TASK, worker_id=WORKER, actor_identity=ACTOR)
|
|
assert codes(proof) == {
|
|
"STALE_OR_INACTIVE_WORKER", "MISSING_CAPABILITY", "EXACT_SCOPE_MISMATCH",
|
|
"AUTHORIZATION_BLOCKED", "CAPACITY_EXHAUSTED", "DEPENDENCY_BLOCKED",
|
|
"FREEZE_BLOCKED", "CONFLICTING_LEASE",
|
|
}
|
|
assert not proof.claimable
|
|
|
|
|
|
def test_a02_healthy_worker_is_claimable_and_proof_is_non_bearer(env):
|
|
_, proofs, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=False
|
|
)
|
|
assert proof.claimable
|
|
assert proofs.get(proof.proof_id) is None
|
|
|
|
|
|
def test_a03_explicit_zero_capacity_is_exhausted(env):
|
|
authority, _, service = env
|
|
authority.worker["max_concurrency"] = 0
|
|
proof = service.preflight(task_id=TASK, worker_id=WORKER, actor_identity=ACTOR)
|
|
assert proof.observation.capacity_limit == 0
|
|
assert "CAPACITY_EXHAUSTED" in codes(proof)
|
|
assert not proof.claimable
|
|
|
|
|
|
@pytest.mark.parametrize("value", [-1, True, "2", 1.5])
|
|
def test_invalid_capacity_is_structured_blocker(env, value):
|
|
authority, _, service = env
|
|
authority.worker["max_concurrency"] = value
|
|
proof = service.preflight(task_id=TASK, worker_id=WORKER, actor_identity=ACTOR)
|
|
assert "INVALID_AUTHORITY_VALUE" in codes(proof)
|
|
assert not proof.claimable
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["COMPLETED", "SUCCEEDED", "FAILED_FINAL", "CANCELLED", "RUNNING"])
|
|
def test_terminal_or_running_task_is_not_claimable(env, status):
|
|
authority, _, service = env
|
|
authority.task["status"] = status
|
|
proof = service.preflight(task_id=TASK, worker_id=WORKER, actor_identity=ACTOR)
|
|
assert "TASK_STATE_NOT_CLAIMABLE" in codes(proof)
|
|
assert not proof.claimable
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutation", "expected"),
|
|
[
|
|
(lambda a: a.worker.__setitem__("last_heartbeat", NOW - 999), "STALE_OR_INACTIVE_WORKER"),
|
|
(lambda a: a.worker.__setitem__("capabilities", ["task.execute"]), "MISSING_CAPABILITY"),
|
|
(lambda a: setattr(a, "scope_ok", False), "EXACT_SCOPE_MISMATCH"),
|
|
(lambda a: a.auth.__setitem__("allowed", False), "AUTHORIZATION_BLOCKED"),
|
|
(lambda a: setattr(a, "active_claims", 2), "CAPACITY_EXHAUSTED"),
|
|
(lambda a: setattr(a, "deps", False), "DEPENDENCY_BLOCKED"),
|
|
(lambda a: setattr(a, "freeze", True), "FREEZE_BLOCKED"),
|
|
(lambda a: setattr(a, "lease", True), "CONFLICTING_LEASE"),
|
|
],
|
|
)
|
|
def test_preflight_specific_blockers(env, mutation, expected):
|
|
authority, _, service = env
|
|
mutation(authority)
|
|
proof = service.preflight(task_id=TASK, worker_id=WORKER, actor_identity=ACTOR)
|
|
assert expected in codes(proof)
|
|
assert not proof.claimable
|
|
|
|
|
|
def test_cross_actor_proof_replay_is_rejected(env):
|
|
_, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
with pytest.raises(SchedulabilityChanged, match="identity mismatch"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity="other"
|
|
)
|
|
|
|
|
|
def test_cross_worker_proof_replay_is_rejected(env):
|
|
_, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
with pytest.raises(SchedulabilityChanged, match="identity mismatch"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id="worker-b", actor_identity=ACTOR
|
|
)
|
|
|
|
|
|
def test_expired_proof_is_rejected(env):
|
|
authority, proofs, _ = env
|
|
clock = [NOW]
|
|
service = SchedulabilityService(authority, proofs, clock=lambda: clock[0], proof_ttl_seconds=1)
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
clock[0] += 2
|
|
with pytest.raises(SchedulabilityChanged, match="expired"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"mutation",
|
|
[
|
|
lambda a: a.worker.__setitem__("registration_revision", 12),
|
|
lambda a: a.task.__setitem__("revision", 8),
|
|
lambda a: a.task.__setitem__("generation", 4),
|
|
lambda a: a.auth.__setitem__("revision", "5"),
|
|
lambda a: setattr(a, "active_claims", 1),
|
|
lambda a: setattr(a, "scope_digest", "scope:" + "b" * 32),
|
|
lambda a: a.task.__setitem__("status", "COMPLETED"),
|
|
],
|
|
)
|
|
def test_claim_time_stable_authority_change_invalidates_proof(env, mutation):
|
|
authority, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
mutation(authority)
|
|
with pytest.raises(SchedulabilityChanged):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR
|
|
)
|
|
|
|
|
|
def test_atomic_linearization_rechecks_capacity_after_read_recheck(env):
|
|
authority, _, service = env
|
|
authority.worker["max_concurrency"] = 1
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
authority.before_atomic_claim = lambda a: setattr(a, "active_claims", 1)
|
|
with pytest.raises(SchedulabilityChanged, match="capacity"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR
|
|
)
|
|
assert authority.claim_calls == []
|
|
|
|
|
|
def test_atomic_linearization_rechecks_authorization_after_read_recheck(env):
|
|
authority, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
authority.before_atomic_claim = lambda a: a.auth.__setitem__("allowed", False)
|
|
with pytest.raises(SchedulabilityChanged, match="authorization"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR
|
|
)
|
|
|
|
|
|
def test_atomic_linearization_rechecks_freeze_after_read_recheck(env):
|
|
authority, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
authority.before_atomic_claim = lambda a: setattr(a, "freeze", True)
|
|
with pytest.raises(SchedulabilityChanged, match="freeze"):
|
|
service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR
|
|
)
|
|
|
|
|
|
def test_normal_claim_requires_lease_and_fence_receipt(env):
|
|
authority, _, service = env
|
|
proof = service.preflight(
|
|
task_id=TASK, worker_id=WORKER, actor_identity=ACTOR, persist=True
|
|
)
|
|
out = service.claim_with_recheck(
|
|
proof_id=proof.proof_id, worker_id=WORKER, actor_identity=ACTOR, lease_seconds=45
|
|
)
|
|
assert out["status"] == "CLAIMED"
|
|
assert out["claim_id"].startswith("claim-")
|
|
assert out["lease_id"].startswith("lease-")
|
|
assert out["fencing_generation"] > 0
|
|
assert out["receipt_revision"] == 8
|
|
assert len(authority.claim_calls) == 1
|
|
|
|
|
|
def test_discovery_skips_blocked_high_priority_and_selects_next_claimable(env):
|
|
authority, proofs, service = env
|
|
blocked = dict(authority.task)
|
|
blocked["task_id"] = "blocked"
|
|
blocked["priority"] = 100
|
|
good = dict(authority.task)
|
|
good["task_id"] = TASK
|
|
authority.candidates = [blocked, good]
|
|
|
|
original_read = authority.read_task
|
|
|
|
def read_task(task_id):
|
|
if task_id == "blocked":
|
|
value = dict(blocked)
|
|
value["required_capabilities"] = ["missing-cap"]
|
|
return value
|
|
return original_read(task_id)
|
|
|
|
authority.read_task = read_task
|
|
proof = service.discover(worker_id=WORKER, actor_identity=ACTOR)
|
|
assert proof is not None
|
|
assert proof.task_id == TASK
|
|
assert proofs.get(proof.proof_id) is proof
|
|
|
|
|
|
def test_auto_claim_diagnostics_do_not_mask_authorization(env):
|
|
authority, _, service = env
|
|
coordinator = AutoClaimCoordinator(service)
|
|
authority.auth["allowed"] = False
|
|
waiting = coordinator.discover(worker_id=WORKER, actor_identity=ACTOR)
|
|
assert waiting.status == "WAITING_AUTHORIZATION"
|
|
assert "AUTHORIZATION_BLOCKED" in waiting.blocker_codes
|
|
|
|
|
|
def test_auto_claim_reports_no_candidates(env):
|
|
authority, _, service = env
|
|
authority.candidates = []
|
|
out = AutoClaimCoordinator(service).discover(worker_id=WORKER, actor_identity=ACTOR)
|
|
assert out.status == "NO_CANDIDATES"
|
|
assert out.candidate_count == 0
|
|
|
|
|
|
def test_auto_claim_capacity_wait_is_precise(env):
|
|
authority, _, service = env
|
|
authority.active_claims = 2
|
|
out = AutoClaimCoordinator(service).discover(worker_id=WORKER, actor_identity=ACTOR)
|
|
assert out.status == "WAITING_CAPACITY"
|
|
assert out.blocker_codes == ("CAPACITY_EXHAUSTED",)
|