1842 lines
74 KiB
Python
1842 lines
74 KiB
Python
"""SQLite-backed durable store with CAS revisions, WAL mode, and append-only events."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from contextlib import contextmanager
|
|
from typing import Any
|
|
|
|
from .capabilities import has_capabilities
|
|
from .errors import (
|
|
ConflictError,
|
|
LockedError,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from .models import (
|
|
Budget,
|
|
EvidenceEnvelope,
|
|
ProbeEvidence,
|
|
ProbeOutcome,
|
|
TaskPlan,
|
|
TaskRecord,
|
|
WorkerClaim,
|
|
WorkerRegistration,
|
|
canonical_json,
|
|
redact_value,
|
|
validate_identifier,
|
|
)
|
|
|
|
|
|
EventEnvelope = tuple[str, str, str, str, str, float]
|
|
OutboxRow = tuple[str, str, str, str, str, str, str, float, float | None, int, str | None]
|
|
|
|
|
|
def _canonical(v: Any) -> str:
|
|
return canonical_json(v)
|
|
|
|
|
|
def _hash_hex(value: str) -> str:
|
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _now() -> float:
|
|
return time.time()
|
|
|
|
|
|
def _validated_id(value: str, name: str) -> str:
|
|
try:
|
|
return validate_identifier(value, name)
|
|
except ValueError as exc:
|
|
raise ValidationError(f"invalid {name}") from exc
|
|
|
|
|
|
class TaskPlaneStore:
|
|
"""Durable SQLite store with atomic operations and audit-ready events."""
|
|
|
|
def __init__(self, db_path: str = ":memory:", *, high_assurance: bool = False) -> None:
|
|
self.db_path = db_path
|
|
self.high_assurance = high_assurance
|
|
self.fault_injector = None
|
|
self.probe_attestation_verifier = None
|
|
self.evidence_attestation_verifier = None
|
|
self._lock = threading.RLock()
|
|
self._tx_state = threading.local()
|
|
self._closed = False
|
|
self._init_db(db_path)
|
|
|
|
def _init_db(self, path: str) -> None:
|
|
if path != ":memory:":
|
|
parent = os.path.dirname(os.path.abspath(path))
|
|
os.makedirs(parent, mode=0o700, exist_ok=True)
|
|
if os.path.islink(parent):
|
|
raise PermissionError("task plane database directory must not be symlink")
|
|
os.chmod(parent, 0o700)
|
|
self.conn = sqlite3.connect(
|
|
path, check_same_thread=False, isolation_level=None
|
|
)
|
|
if path != ":memory:":
|
|
os.chmod(path, 0o600)
|
|
self.conn.row_factory = sqlite3.Row
|
|
self.conn.execute("PRAGMA busy_timeout=5000;")
|
|
if path != ":memory:":
|
|
self.conn.execute("PRAGMA journal_mode=WAL;")
|
|
self.conn.execute(
|
|
f"PRAGMA synchronous={'FULL' if self.high_assurance else 'NORMAL'};"
|
|
)
|
|
self.conn.execute("PRAGMA foreign_keys=ON;")
|
|
self._create_schema()
|
|
|
|
def close(self) -> None:
|
|
with self._lock:
|
|
if not self._closed:
|
|
self.conn.close()
|
|
self._closed = True
|
|
|
|
def __del__(self) -> None:
|
|
try:
|
|
self.close()
|
|
except Exception:
|
|
pass
|
|
|
|
@contextmanager
|
|
def _transaction(self):
|
|
"""Serialize one connection and keep nested writes in one transaction."""
|
|
with self._lock:
|
|
depth = getattr(self._tx_state, "depth", 0)
|
|
if depth == 0:
|
|
self.conn.execute("BEGIN IMMEDIATE")
|
|
self._tx_state.depth = depth + 1
|
|
try:
|
|
yield
|
|
except Exception:
|
|
self._tx_state.depth -= 1
|
|
if depth == 0:
|
|
self.conn.rollback()
|
|
raise
|
|
else:
|
|
self._tx_state.depth -= 1
|
|
if depth == 0:
|
|
self.conn.commit()
|
|
|
|
def _create_schema(self) -> None:
|
|
with self._transaction():
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS 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
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS workers (
|
|
worker_id TEXT PRIMARY KEY,
|
|
worker_type TEXT NOT NULL,
|
|
identity TEXT NOT NULL,
|
|
capabilities TEXT NOT NULL,
|
|
trust_zone TEXT NOT NULL,
|
|
max_concurrency INTEGER NOT NULL,
|
|
heartbeat_interval_seconds INTEGER NOT NULL,
|
|
supported_protocols TEXT NOT NULL,
|
|
registered_at REAL NOT NULL,
|
|
last_heartbeat REAL NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
disabled INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS claims (
|
|
claim_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
worker_id TEXT NOT NULL,
|
|
worker_identity TEXT NOT NULL,
|
|
claimed_at REAL NOT NULL,
|
|
lease_until REAL NOT NULL,
|
|
attempt INTEGER NOT NULL,
|
|
task_revision INTEGER NOT NULL,
|
|
operation_digest TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
completed_at REAL,
|
|
result_payload TEXT,
|
|
UNIQUE(task_id, worker_id, claim_id)
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
event_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
seq INTEGER NOT NULL,
|
|
type TEXT NOT NULL,
|
|
event_data TEXT NOT NULL,
|
|
subject TEXT,
|
|
created_at REAL NOT NULL,
|
|
prev_hash TEXT NOT NULL,
|
|
hash TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS outbox (
|
|
message_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
task_status TEXT,
|
|
event_type TEXT NOT NULL,
|
|
stage TEXT,
|
|
correlation_id TEXT,
|
|
payload TEXT NOT NULL,
|
|
delivery_status TEXT NOT NULL DEFAULT 'NOT_DELIVERED',
|
|
created_at REAL NOT NULL,
|
|
delivered_at REAL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
receipt TEXT
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS evidence (
|
|
evidence_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
kind TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
digest TEXT NOT NULL,
|
|
worker_identity TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
outbox_columns = {
|
|
row["name"] for row in self.conn.execute("PRAGMA table_info(outbox)")
|
|
}
|
|
if "receipt" not in outbox_columns:
|
|
self.conn.execute("ALTER TABLE outbox ADD COLUMN receipt TEXT")
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS delegations (
|
|
parent_task_id TEXT NOT NULL,
|
|
child_task_id TEXT PRIMARY KEY,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_budget (
|
|
task_id TEXT PRIMARY KEY,
|
|
time_seconds INTEGER,
|
|
attempts INTEGER,
|
|
tokens INTEGER,
|
|
cost REAL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS probe_evidence (
|
|
probe_id TEXT PRIMARY KEY,
|
|
task_id TEXT NOT NULL,
|
|
claim_id TEXT NOT NULL,
|
|
envelope_json TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self._ensure_columns(
|
|
"tasks",
|
|
{
|
|
"owner_principal_id": "TEXT NOT NULL DEFAULT ''",
|
|
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
|
},
|
|
)
|
|
self._ensure_columns(
|
|
"workers",
|
|
{
|
|
"owner_identity": "TEXT NOT NULL DEFAULT ''",
|
|
"registration_revision": "INTEGER NOT NULL DEFAULT 1",
|
|
"registration_policy": "TEXT NOT NULL DEFAULT 'self'",
|
|
"created_at": "REAL NOT NULL DEFAULT 0",
|
|
"updated_at": "REAL NOT NULL DEFAULT 0",
|
|
"disabled_reason": "TEXT",
|
|
},
|
|
)
|
|
self._ensure_columns(
|
|
"evidence",
|
|
{
|
|
"claim_id": "TEXT NOT NULL DEFAULT ''",
|
|
"previous_evidence_hash": "TEXT NOT NULL DEFAULT ''",
|
|
"envelope_json": "TEXT",
|
|
},
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO schema_meta(key,value) VALUES('schema_version','2')
|
|
ON CONFLICT(key) DO UPDATE SET value=excluded.value
|
|
"""
|
|
)
|
|
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_workers_status ON workers(status)")
|
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
|
self.conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_events_task_seq ON events(task_id, seq)"
|
|
)
|
|
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox(delivery_status)")
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_lineage_sequences (
|
|
parent_task_id TEXT NOT NULL,
|
|
relation_type TEXT NOT NULL,
|
|
child_task_id TEXT NOT NULL UNIQUE,
|
|
sibling_sequence INTEGER NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
PRIMARY KEY(parent_task_id, relation_type, sibling_sequence)
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_terminal_proofs (
|
|
task_id TEXT PRIMARY KEY,
|
|
claim_id TEXT NOT NULL UNIQUE,
|
|
task_revision INTEGER NOT NULL,
|
|
result_digest TEXT NOT NULL,
|
|
evidence_refs_json TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_root_reviews (
|
|
review_id TEXT PRIMARY KEY,
|
|
root_task_id TEXT NOT NULL,
|
|
trigger_child_task_id TEXT NOT NULL,
|
|
trigger_child_revision INTEGER NOT NULL,
|
|
root_revision_before INTEGER NOT NULL,
|
|
root_revision_after INTEGER NOT NULL,
|
|
record_json TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_root_action_dispatches (
|
|
review_id TEXT PRIMARY KEY,
|
|
root_task_id TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL UNIQUE,
|
|
status TEXT NOT NULL,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self._ensure_columns(
|
|
"task_root_action_dispatches",
|
|
{
|
|
"mission_id": "TEXT NOT NULL DEFAULT ''",
|
|
"root_revision": "INTEGER NOT NULL DEFAULT 0",
|
|
"row_version": "INTEGER NOT NULL DEFAULT 0",
|
|
"plan_json": "TEXT",
|
|
"plan_digest": "TEXT",
|
|
"execution_key": "TEXT",
|
|
"claim_owner": "TEXT",
|
|
"claim_token": "TEXT",
|
|
"claim_fence": "INTEGER",
|
|
"claim_expires_at": "REAL",
|
|
"execution_receipt_json": "TEXT",
|
|
"execution_receipt_digest": "TEXT",
|
|
"rereview_json": "TEXT",
|
|
"rereview_digest": "TEXT",
|
|
"retry_at": "REAL",
|
|
"last_error_code": "TEXT",
|
|
"updated_at": "REAL",
|
|
},
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_root_action_effects (
|
|
execution_key TEXT PRIMARY KEY,
|
|
root_task_id TEXT NOT NULL,
|
|
target_task_id TEXT,
|
|
target_job_id TEXT,
|
|
request_digest TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
effect_phase TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN',
|
|
receipt_json TEXT,
|
|
receipt_digest TEXT,
|
|
retry_at REAL,
|
|
last_error_code TEXT,
|
|
created_at REAL NOT NULL,
|
|
updated_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self._ensure_columns(
|
|
"task_root_action_effects",
|
|
{
|
|
# Existing v1/v2 EFFECT_UNKNOWN rows may already have sent
|
|
# an external request. They must never migrate to a phase
|
|
# that permits a new lease or commit.
|
|
"effect_phase": "TEXT NOT NULL DEFAULT 'LEGACY_UNKNOWN'",
|
|
},
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_root_action_fences (
|
|
root_task_id TEXT PRIMARY KEY,
|
|
next_fence INTEGER NOT NULL,
|
|
active_review_id TEXT,
|
|
active_fence INTEGER,
|
|
active_phase TEXT,
|
|
updated_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_ready "
|
|
"ON task_root_action_dispatches(status,retry_at,created_at)"
|
|
)
|
|
self.conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS ix_root_action_dispatch_root "
|
|
"ON task_root_action_dispatches(root_task_id,root_revision)"
|
|
)
|
|
self.conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS ux_root_review_trigger "
|
|
"ON task_root_reviews(trigger_child_task_id, trigger_child_revision)"
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_missions (
|
|
mission_id TEXT PRIMARY KEY,
|
|
root_task_id TEXT NOT NULL UNIQUE,
|
|
authenticated_identity TEXT NOT NULL,
|
|
owner_principal_id TEXT NOT NULL,
|
|
owner_alias TEXT NOT NULL,
|
|
goal_slug TEXT NOT NULL,
|
|
root_acceptance_ref TEXT NOT NULL UNIQUE,
|
|
acceptance_verifier_id TEXT NOT NULL,
|
|
original_acceptance_json TEXT NOT NULL,
|
|
request_digest TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
UNIQUE(authenticated_identity, idempotency_key)
|
|
)
|
|
"""
|
|
)
|
|
self.conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS ix_task_missions_owner "
|
|
"ON task_missions(authenticated_identity, mission_id)"
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS task_mission_delegations (
|
|
authenticated_identity TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL,
|
|
mission_id TEXT NOT NULL,
|
|
parent_task_id TEXT NOT NULL,
|
|
child_task_id TEXT NOT NULL UNIQUE,
|
|
request_digest TEXT NOT NULL,
|
|
created_at REAL NOT NULL,
|
|
PRIMARY KEY(authenticated_identity, idempotency_key)
|
|
)
|
|
"""
|
|
)
|
|
|
|
def _ensure_columns(self, table: str, columns: dict[str, str]) -> None:
|
|
existing = {
|
|
row["name"] for row in self.conn.execute(f"PRAGMA table_info({table})")
|
|
}
|
|
for name, declaration in columns.items():
|
|
if name not in existing:
|
|
self.conn.execute(
|
|
f"ALTER TABLE {table} ADD COLUMN {name} {declaration}"
|
|
)
|
|
|
|
def _fault(self, point: str) -> None:
|
|
if self.fault_injector is not None:
|
|
self.fault_injector(point)
|
|
|
|
def schema_version(self) -> int:
|
|
row = self.conn.execute(
|
|
"SELECT value FROM schema_meta WHERE key='schema_version'"
|
|
).fetchone()
|
|
return int(row["value"]) if row else 0
|
|
|
|
def integrity_check(self) -> str:
|
|
return str(self.conn.execute("PRAGMA integrity_check").fetchone()[0])
|
|
|
|
def checkpoint_and_backup(self, destination: str) -> None:
|
|
target = os.path.abspath(destination)
|
|
os.makedirs(os.path.dirname(target), mode=0o700, exist_ok=True)
|
|
with self._lock:
|
|
if self.db_path != ":memory:":
|
|
self.conn.execute("PRAGMA wal_checkpoint(FULL)")
|
|
backup = sqlite3.connect(target)
|
|
try:
|
|
self.conn.backup(backup)
|
|
finally:
|
|
backup.close()
|
|
os.chmod(target, 0o600)
|
|
|
|
def _row_to_task(self, row: sqlite3.Row | None) -> TaskRecord:
|
|
if row is None:
|
|
raise NotFoundError("task")
|
|
task_json = json.loads(row["task_json"])
|
|
task_plan = TaskPlan.from_dict(task_json)
|
|
task_plan.parent_task_id = row["parent_task_id"]
|
|
task_plan.correlation_id = row["correlation_id"]
|
|
return TaskRecord(
|
|
task_id=row["task_id"],
|
|
status=row["status"],
|
|
revision=row["revision"],
|
|
task_plan=task_plan,
|
|
created_at=row["created_at"],
|
|
updated_at=row["updated_at"],
|
|
root_task_id=row["root_task_id"],
|
|
idempotency_key=row["idempotency_key"],
|
|
failure_reason=row["failure_reason"],
|
|
owner_principal_id=row["owner_principal_id"],
|
|
mission_id=row["mission_id"],
|
|
)
|
|
|
|
def _row_to_worker(self, row: sqlite3.Row | None) -> WorkerRegistration:
|
|
if row is None:
|
|
raise NotFoundError("worker")
|
|
return WorkerRegistration(
|
|
worker_id=row["worker_id"],
|
|
worker_type=row["worker_type"],
|
|
identity=row["identity"],
|
|
capabilities=json.loads(row["capabilities"]),
|
|
trust_zone=row["trust_zone"],
|
|
max_concurrency=row["max_concurrency"],
|
|
heartbeat_interval_seconds=row["heartbeat_interval_seconds"],
|
|
supported_protocols=json.loads(row["supported_protocols"]),
|
|
registered_at=row["registered_at"],
|
|
last_heartbeat=row["last_heartbeat"],
|
|
status=row["status"],
|
|
disabled=bool(row["disabled"]),
|
|
owner_identity=row["owner_identity"] or row["identity"],
|
|
registration_revision=row["registration_revision"],
|
|
registration_policy=row["registration_policy"],
|
|
created_at=row["created_at"] or row["registered_at"],
|
|
updated_at=row["updated_at"] or row["last_heartbeat"],
|
|
disabled_reason=row["disabled_reason"],
|
|
)
|
|
|
|
def create_task(
|
|
self,
|
|
task_plan: TaskPlan,
|
|
idempotency_key: str | None = None,
|
|
*,
|
|
owner_principal_id: str = "",
|
|
mission_id: str = "",
|
|
created_at: float | None = None,
|
|
) -> TaskRecord:
|
|
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
|
task_plan.task_id = task_id
|
|
task_json = redact_value(task_plan.to_dict(redact=False))
|
|
now = _now() if created_at is None else float(created_at)
|
|
with self._transaction():
|
|
if idempotency_key:
|
|
existing = self.conn.execute(
|
|
"SELECT task_id,task_json,owner_principal_id,mission_id FROM tasks WHERE idempotency_key=?",
|
|
(idempotency_key,),
|
|
).fetchone()
|
|
if existing:
|
|
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
|
raise ConflictError("idempotency key payload mismatch")
|
|
if owner_principal_id and existing["owner_principal_id"] != owner_principal_id:
|
|
raise ConflictError("idempotency key owner mismatch")
|
|
if mission_id and existing["mission_id"] != mission_id:
|
|
raise ConflictError("idempotency key mission mismatch")
|
|
return self.get_task(existing["task_id"])
|
|
root_task_id = task_id
|
|
for dependency in task_plan.dependencies:
|
|
dependency_id = _validated_id(dependency, "dependency task_id")
|
|
if dependency_id == task_id:
|
|
raise ConflictError("task dependency cycle")
|
|
self.get_task(dependency_id)
|
|
if task_plan.parent_task_id:
|
|
parent = self.get_task(task_plan.parent_task_id)
|
|
root_task_id = parent.root_task_id
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO tasks
|
|
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason, owner_principal_id, mission_id)
|
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
""",
|
|
(
|
|
task_id,
|
|
"CREATED",
|
|
json.dumps(task_json, ensure_ascii=False),
|
|
task_plan.task_type,
|
|
root_task_id,
|
|
task_plan.parent_task_id,
|
|
task_plan.correlation_id,
|
|
idempotency_key,
|
|
now,
|
|
now,
|
|
owner_principal_id,
|
|
mission_id,
|
|
),
|
|
)
|
|
self._set_budget(task_id, task_plan.budgets)
|
|
if task_plan.parent_task_id:
|
|
self.conn.execute(
|
|
"INSERT INTO delegations (parent_task_id, child_task_id, created_at) VALUES (?, ?, ?)",
|
|
(task_plan.parent_task_id, task_id, now),
|
|
)
|
|
return self.get_task(task_id)
|
|
|
|
def create_task_with_event(
|
|
self,
|
|
task_plan: TaskPlan,
|
|
idempotency_key: str | None = None,
|
|
*,
|
|
owner_principal_id: str = "",
|
|
mission_id: str = "",
|
|
created_at: float | None = None,
|
|
) -> TaskRecord:
|
|
with self._transaction():
|
|
task = self.create_task(
|
|
task_plan,
|
|
idempotency_key=idempotency_key,
|
|
owner_principal_id=owner_principal_id,
|
|
mission_id=mission_id,
|
|
created_at=created_at,
|
|
)
|
|
if not self.list_events(task.task_id):
|
|
self._fault("create.after_task_before_event")
|
|
self.append_event(
|
|
task.task_id,
|
|
"TASK_CREATED",
|
|
{"task_id": task.task_id},
|
|
subject=f"task/{task.task_id}",
|
|
)
|
|
return self.get_task(task.task_id)
|
|
|
|
def get_task(self, task_id: str) -> TaskRecord:
|
|
task_id = _validated_id(task_id, "task_id")
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError(f"task {task_id} not found")
|
|
return self._row_to_task(row)
|
|
|
|
def assert_root_open_for_child(self, root_task_id: str) -> TaskRecord:
|
|
"""Fence child admission against root completion/action finalization."""
|
|
root = self.get_task(root_task_id)
|
|
if root.task_id != root.root_task_id:
|
|
raise ConflictError("child admission authority is not a root task")
|
|
if root.status == "COMPLETED":
|
|
raise ConflictError("cannot add a child below a completed root")
|
|
fence = self.conn.execute(
|
|
"SELECT active_phase FROM task_root_action_fences WHERE root_task_id=?",
|
|
(root_task_id,),
|
|
).fetchone()
|
|
if fence is not None and fence["active_phase"] in {
|
|
"CLAIMED",
|
|
"EFFECT_UNKNOWN",
|
|
"EXECUTED_PENDING_REREVIEW",
|
|
"ROOT_CLOSING",
|
|
}:
|
|
raise ConflictError("root action fence blocks child admission")
|
|
return root
|
|
|
|
def list_tasks(self) -> list[TaskRecord]:
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT * FROM tasks ORDER BY created_at DESC"
|
|
).fetchall()
|
|
return [self._row_to_task(row) for row in rows]
|
|
|
|
def get_task_budget(self, task_id: str) -> Budget:
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT * FROM task_budget WHERE task_id=?",
|
|
(task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return Budget()
|
|
return Budget(
|
|
time_seconds=row["time_seconds"],
|
|
attempts=row["attempts"],
|
|
tokens=row["tokens"],
|
|
cost=row["cost"],
|
|
)
|
|
|
|
def _set_budget(self, task_id: str, budget: Budget) -> None:
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO task_budget(task_id, time_seconds, attempts, tokens, cost)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(task_id) DO UPDATE SET
|
|
time_seconds=excluded.time_seconds,
|
|
attempts=excluded.attempts,
|
|
tokens=excluded.tokens,
|
|
cost=excluded.cost
|
|
""",
|
|
(task_id, budget.time_seconds, budget.attempts, budget.tokens, budget.cost),
|
|
)
|
|
|
|
def consume_budget(self, task_id: str, needed: Budget) -> Budget:
|
|
with self._transaction():
|
|
row = self.conn.execute(
|
|
"SELECT time_seconds, attempts, tokens, cost FROM task_budget WHERE task_id=?",
|
|
(task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("task budget")
|
|
current = Budget(row["time_seconds"], row["attempts"], row["tokens"], row["cost"])
|
|
|
|
for key in ("time_seconds", "attempts", "tokens"):
|
|
needed_value = getattr(needed, key)
|
|
current_value = getattr(current, key)
|
|
if needed_value is not None and current_value is not None:
|
|
if needed_value is not None and current_value < needed_value:
|
|
raise ConflictError(f"budget exceeded for {key}")
|
|
|
|
updated = Budget(
|
|
time_seconds=current.time_seconds - needed.time_seconds if needed.time_seconds is not None and current.time_seconds is not None else current.time_seconds,
|
|
attempts=current.attempts - needed.attempts if needed.attempts is not None and current.attempts is not None else current.attempts,
|
|
tokens=current.tokens - needed.tokens if needed.tokens is not None and current.tokens is not None else current.tokens,
|
|
cost=current.cost - needed.cost if needed.cost is not None and current.cost is not None else current.cost,
|
|
)
|
|
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE task_budget
|
|
SET time_seconds=?, attempts=?, tokens=?, cost=?
|
|
WHERE task_id=?
|
|
""",
|
|
(updated.time_seconds, updated.attempts, updated.tokens, updated.cost, task_id),
|
|
)
|
|
return updated
|
|
|
|
def update_task_status(
|
|
self,
|
|
task_id: str,
|
|
expected_revision: int,
|
|
status: str,
|
|
failure_reason: str | None = None,
|
|
) -> TaskRecord:
|
|
with self._transaction():
|
|
current = self.get_task(task_id)
|
|
if current.revision != expected_revision:
|
|
raise ConflictError("task revision mismatch")
|
|
current.with_status(status)
|
|
|
|
now = _now()
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE tasks
|
|
SET status=?, revision=revision+1, updated_at=?, failure_reason=?
|
|
WHERE task_id=?
|
|
""",
|
|
(status, now, failure_reason, task_id),
|
|
)
|
|
return self.get_task(task_id)
|
|
|
|
def register_worker(
|
|
self,
|
|
registration: WorkerRegistration | dict[str, Any],
|
|
*,
|
|
actor_identity: str | None = None,
|
|
is_admin: bool = False,
|
|
expected_revision: int | None = None,
|
|
) -> WorkerRegistration:
|
|
if isinstance(registration, dict):
|
|
registration = WorkerRegistration.from_dict(registration)
|
|
actor_identity = actor_identity or registration.identity
|
|
with self._transaction():
|
|
existing_row = self.conn.execute(
|
|
"SELECT * FROM workers WHERE worker_id=?", (registration.worker_id,)
|
|
).fetchone()
|
|
if existing_row is not None:
|
|
existing = self._row_to_worker(existing_row)
|
|
if existing.disabled and not is_admin:
|
|
raise LockedError("worker disabled")
|
|
if existing.identity != registration.identity:
|
|
raise ConflictError("worker identity takeover rejected")
|
|
if actor_identity != existing.owner_identity and not is_admin:
|
|
raise ConflictError("worker owner identity mismatch")
|
|
if expected_revision is None or expected_revision != existing.registration_revision:
|
|
raise ConflictError("worker registration revision mismatch")
|
|
escalated = (
|
|
not set(registration.capabilities).issubset(existing.capabilities)
|
|
or registration.max_concurrency > existing.max_concurrency
|
|
or registration.trust_zone != existing.trust_zone
|
|
)
|
|
if escalated and not is_admin:
|
|
from .errors import ForbiddenError
|
|
|
|
raise ForbiddenError("worker privilege escalation requires admin")
|
|
revision = existing.registration_revision + 1
|
|
created_at = existing.created_at
|
|
else:
|
|
if actor_identity != registration.identity and not is_admin:
|
|
from .errors import ForbiddenError
|
|
|
|
raise ForbiddenError("self registration identity mismatch")
|
|
revision = 1
|
|
created_at = registration.created_at or registration.registered_at
|
|
now = _now()
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO workers
|
|
(worker_id, worker_type, identity, capabilities, trust_zone, max_concurrency,
|
|
heartbeat_interval_seconds, supported_protocols, registered_at, last_heartbeat,
|
|
status, disabled, owner_identity, registration_revision, registration_policy,
|
|
created_at, updated_at, disabled_reason)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(worker_id) DO UPDATE SET
|
|
worker_type=excluded.worker_type,
|
|
capabilities=excluded.capabilities,
|
|
trust_zone=excluded.trust_zone,
|
|
max_concurrency=excluded.max_concurrency,
|
|
heartbeat_interval_seconds=excluded.heartbeat_interval_seconds,
|
|
supported_protocols=excluded.supported_protocols,
|
|
last_heartbeat=excluded.last_heartbeat,
|
|
status=excluded.status,
|
|
disabled=excluded.disabled,
|
|
registration_revision=excluded.registration_revision,
|
|
registration_policy=excluded.registration_policy,
|
|
updated_at=excluded.updated_at,
|
|
disabled_reason=excluded.disabled_reason
|
|
""",
|
|
(
|
|
registration.worker_id,
|
|
registration.worker_type,
|
|
registration.identity,
|
|
json.dumps(registration.capabilities, ensure_ascii=False),
|
|
registration.trust_zone,
|
|
registration.max_concurrency,
|
|
registration.heartbeat_interval_seconds,
|
|
json.dumps(registration.supported_protocols, ensure_ascii=False),
|
|
registration.registered_at,
|
|
registration.last_heartbeat,
|
|
registration.status,
|
|
int(registration.disabled),
|
|
registration.owner_identity or registration.identity,
|
|
revision,
|
|
registration.registration_policy,
|
|
created_at,
|
|
now,
|
|
registration.disabled_reason,
|
|
),
|
|
)
|
|
self._append_worker_event(
|
|
registration.worker_id,
|
|
"WORKER_REGISTERED" if existing_row is None else "WORKER_UPDATED",
|
|
actor_identity,
|
|
revision,
|
|
)
|
|
return self.get_worker(registration.worker_id)
|
|
|
|
def _append_worker_event(
|
|
self, worker_id: str, event_type: str, actor_identity: str, revision: int
|
|
) -> None:
|
|
# Worker audit records live in an append-only synthetic task stream.
|
|
audit_task_id = "task-worker-registry"
|
|
row = self.conn.execute(
|
|
"SELECT task_id FROM tasks WHERE task_id=?", (audit_task_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
plan = TaskPlan.from_dict(
|
|
{
|
|
"task_id": audit_task_id,
|
|
"task_type": "worker.registry.audit",
|
|
"issuer": {"type": "service", "id": "task-plane"},
|
|
}
|
|
)
|
|
self.create_task(plan)
|
|
self.append_event(
|
|
audit_task_id,
|
|
event_type,
|
|
{
|
|
"worker_id": worker_id,
|
|
"actor_identity": actor_identity,
|
|
"registration_revision": revision,
|
|
},
|
|
subject=f"worker/{worker_id}",
|
|
)
|
|
|
|
def list_workers(self) -> list[WorkerRegistration]:
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT * FROM workers ORDER BY registered_at DESC"
|
|
).fetchall()
|
|
return [self._row_to_worker(r) for r in rows]
|
|
|
|
def get_worker(self, worker_id: str) -> WorkerRegistration:
|
|
worker_id = _validated_id(worker_id, "worker_id")
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT * FROM workers WHERE worker_id=?", (worker_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError(f"worker {worker_id} not found")
|
|
return self._row_to_worker(row)
|
|
|
|
def heartbeat_worker(
|
|
self,
|
|
worker_id: str,
|
|
heartbeat_interval_seconds: int | None = None,
|
|
*,
|
|
actor_identity: str | None = None,
|
|
expected_revision: int | None = None,
|
|
is_admin: bool = False,
|
|
) -> WorkerRegistration:
|
|
with self._transaction():
|
|
worker = self.get_worker(worker_id)
|
|
if worker.disabled:
|
|
raise LockedError("worker disabled")
|
|
if not is_admin and actor_identity != worker.identity:
|
|
from .errors import ForbiddenError
|
|
|
|
raise ForbiddenError("worker heartbeat identity mismatch")
|
|
if expected_revision is None or expected_revision != worker.registration_revision:
|
|
raise ConflictError("worker registration revision mismatch")
|
|
now = _now()
|
|
heartbeat_interval = heartbeat_interval_seconds or worker.heartbeat_interval_seconds
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE workers
|
|
SET last_heartbeat=?, heartbeat_interval_seconds=?,
|
|
registration_revision=registration_revision+1, updated_at=?
|
|
WHERE worker_id=?
|
|
""",
|
|
(now, heartbeat_interval, now, worker_id),
|
|
)
|
|
updated = self.get_worker(worker_id)
|
|
self._append_worker_event(
|
|
worker_id, "WORKER_HEARTBEAT", actor_identity or "", updated.registration_revision
|
|
)
|
|
return updated
|
|
|
|
def disable_worker(
|
|
self,
|
|
worker_id: str,
|
|
*,
|
|
actor_identity: str,
|
|
expected_revision: int,
|
|
reason: str,
|
|
) -> WorkerRegistration:
|
|
with self._transaction():
|
|
worker = self.get_worker(worker_id)
|
|
if worker.registration_revision != expected_revision:
|
|
raise ConflictError("worker registration revision mismatch")
|
|
now = _now()
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE workers SET disabled=1,status='disabled',disabled_reason=?,
|
|
registration_revision=registration_revision+1,updated_at=?
|
|
WHERE worker_id=?
|
|
""",
|
|
(reason, now, worker_id),
|
|
)
|
|
updated = self.get_worker(worker_id)
|
|
self._append_worker_event(
|
|
worker_id, "WORKER_DISABLED", actor_identity, updated.registration_revision
|
|
)
|
|
return updated
|
|
|
|
def restore_worker(
|
|
self,
|
|
worker_id: str,
|
|
*,
|
|
actor_identity: str,
|
|
expected_revision: int,
|
|
) -> WorkerRegistration:
|
|
with self._transaction():
|
|
worker = self.get_worker(worker_id)
|
|
if worker.registration_revision != expected_revision:
|
|
raise ConflictError("worker registration revision mismatch")
|
|
now = _now()
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE workers SET disabled=0,status='active',disabled_reason=NULL,
|
|
registration_revision=registration_revision+1,updated_at=?
|
|
WHERE worker_id=?
|
|
""",
|
|
(now, worker_id),
|
|
)
|
|
updated = self.get_worker(worker_id)
|
|
self._append_worker_event(
|
|
worker_id, "WORKER_RESTORED", actor_identity, updated.registration_revision
|
|
)
|
|
return updated
|
|
|
|
def active_claim(self, task_id: str) -> WorkerClaim | None:
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"""
|
|
SELECT * FROM claims
|
|
WHERE task_id=? AND status='ACTIVE' AND lease_until>?
|
|
ORDER BY claimed_at DESC
|
|
LIMIT 1
|
|
""",
|
|
(task_id, _now()),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
return self._row_to_claim(row)
|
|
|
|
def get_claim(self, claim_id: str) -> WorkerClaim:
|
|
claim_id = _validated_id(claim_id, "claim_id")
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT * FROM claims WHERE claim_id=?", (claim_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError(f"claim {claim_id} not found")
|
|
return self._row_to_claim(row)
|
|
|
|
def _row_to_claim(self, row: sqlite3.Row) -> WorkerClaim:
|
|
return WorkerClaim(
|
|
claim_id=row["claim_id"],
|
|
task_id=row["task_id"],
|
|
worker_id=row["worker_id"],
|
|
worker_identity=row["worker_identity"],
|
|
claimed_at=row["claimed_at"],
|
|
lease_until=row["lease_until"],
|
|
attempt=row["attempt"],
|
|
task_revision=row["task_revision"],
|
|
operation_digest=row["operation_digest"],
|
|
status=row["status"],
|
|
)
|
|
|
|
def claim_task(
|
|
self,
|
|
task_id: str,
|
|
worker_id: str,
|
|
worker_identity: str,
|
|
lease_seconds: int,
|
|
expected_revision: int,
|
|
operation_digest: str,
|
|
) -> WorkerClaim:
|
|
now = _now()
|
|
lease_until = now + max(1, int(lease_seconds))
|
|
with self._transaction():
|
|
worker = self.get_worker(worker_id)
|
|
if worker.disabled:
|
|
raise LockedError("worker disabled")
|
|
if worker.identity != worker_identity:
|
|
raise ConflictError("worker identity mismatch")
|
|
|
|
task = self.get_task(task_id)
|
|
if task.revision != expected_revision:
|
|
raise ConflictError("task revision mismatch")
|
|
if task.status not in {"CREATED", "QUEUED", "FAILED_RECOVERABLE"}:
|
|
raise ConflictError(f"task in invalid state for claim: {task.status}")
|
|
if task.status == "FAILED_RECOVERABLE" and not self.task_has_retryable_probe(
|
|
task_id
|
|
):
|
|
raise LockedError("recoverable task lacks NOT_APPLIED probe evidence")
|
|
if self.active_claim(task_id):
|
|
raise ConflictError("task already claimed")
|
|
if task.task_plan.data_classification == "credential-secret":
|
|
if (
|
|
worker.worker_type != "secure_file_transformer"
|
|
and "secure_file_transformer" not in worker.capabilities
|
|
):
|
|
from .errors import ForbiddenError
|
|
|
|
raise ForbiddenError(
|
|
"credential-secret task requires secure file transformer"
|
|
)
|
|
for dependency_id in task.task_plan.dependencies:
|
|
if self.get_task(dependency_id).status != "COMPLETED":
|
|
raise LockedError("task dependency incomplete")
|
|
|
|
required = task.task_plan.required_capabilities
|
|
if required and not has_capabilities(worker.capabilities, required):
|
|
raise ConflictError("worker lacks required capabilities")
|
|
attempts = self.conn.execute(
|
|
"SELECT COUNT(1) FROM claims WHERE task_id=? AND status='ACTIVE' AND lease_until>?",
|
|
(task_id, now),
|
|
).fetchone()[0]
|
|
if attempts > 0:
|
|
raise ConflictError("existing active claim")
|
|
|
|
claim_id = f"claim-{uuid.uuid4()}"
|
|
active_for_worker = self.conn.execute(
|
|
"SELECT COUNT(1) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",
|
|
(worker_id, now),
|
|
).fetchone()[0]
|
|
if active_for_worker >= worker.max_concurrency:
|
|
raise LockedError("worker concurrency exceeded")
|
|
attempt = self.conn.execute(
|
|
"SELECT COUNT(1) FROM claims WHERE task_id=?",
|
|
(task_id,),
|
|
).fetchone()[0] + 1
|
|
|
|
self.conn.execute(
|
|
"INSERT INTO claims(claim_id, task_id, worker_id, worker_identity, claimed_at, lease_until, attempt, task_revision, operation_digest, status) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'ACTIVE')",
|
|
(
|
|
claim_id,
|
|
task_id,
|
|
worker_id,
|
|
worker_identity,
|
|
now,
|
|
lease_until,
|
|
attempt,
|
|
expected_revision,
|
|
operation_digest,
|
|
),
|
|
)
|
|
schedule_payload = {
|
|
"worker_id": worker_id,
|
|
"worker_identity": worker_identity,
|
|
"operation_digest": operation_digest,
|
|
"attempt": attempt,
|
|
}
|
|
existing_decision = self.conn.execute(
|
|
"""
|
|
SELECT 1 FROM events
|
|
WHERE task_id=? AND type='SCHEDULE_DECISION' AND event_data=?
|
|
""",
|
|
(
|
|
task_id,
|
|
json.dumps(redact_value(schedule_payload), ensure_ascii=False),
|
|
),
|
|
).fetchone()
|
|
if existing_decision is None:
|
|
self.append_event(
|
|
task_id,
|
|
"SCHEDULE_DECISION",
|
|
schedule_payload,
|
|
subject=f"task/{task_id}",
|
|
)
|
|
self.conn.execute(
|
|
"UPDATE tasks SET status='CLAIMED', revision=revision+1, updated_at=? WHERE task_id=?",
|
|
(now, task_id),
|
|
)
|
|
self.append_event(task_id, "TASK_CLAIMED", {"claim_id": claim_id, "worker_id": worker_id}, subject=f"task/{task_id}")
|
|
return self._row_to_claim(self.conn.execute(
|
|
"SELECT * FROM claims WHERE claim_id=?",
|
|
(claim_id,),
|
|
).fetchone())
|
|
|
|
def heartbeat_claim(self, task_id: str, claim_id: str, lease_seconds: int) -> WorkerClaim:
|
|
now = _now()
|
|
lease_until = now + max(1, lease_seconds)
|
|
with self._transaction():
|
|
row = self.conn.execute(
|
|
"SELECT * FROM claims WHERE claim_id=? AND task_id=?",
|
|
(claim_id, task_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("claim")
|
|
claim = self._row_to_claim(row)
|
|
if claim.status != "ACTIVE":
|
|
raise ConflictError("claim not active")
|
|
if claim.lease_until <= now:
|
|
raise LockedError("lease expired")
|
|
if self.get_task(task_id).status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
|
raise ConflictError("task is terminal")
|
|
updated = self.conn.execute(
|
|
"""
|
|
UPDATE claims SET lease_until=?
|
|
WHERE claim_id=? AND task_id=? AND status='ACTIVE' AND lease_until>?
|
|
""",
|
|
(lease_until, claim_id, task_id, now),
|
|
)
|
|
if updated.rowcount != 1:
|
|
raise LockedError("claim changed during heartbeat")
|
|
self.append_event(task_id, "TASK_PROGRESS", {"claim_id": claim_id, "stage": "heartbeat"}, subject=f"task/{task_id}")
|
|
return self.get_claim(claim_id)
|
|
|
|
def record_progress(
|
|
self,
|
|
task_id: str,
|
|
claim_id: str,
|
|
worker_identity: str,
|
|
*,
|
|
progress: int | float,
|
|
stage: str,
|
|
message: str | None,
|
|
lease_seconds: int,
|
|
) -> WorkerClaim:
|
|
if not isinstance(progress, (int, float)) or not 0 <= progress <= 100:
|
|
raise ValidationError("progress must be between 0 and 100")
|
|
now = _now()
|
|
with self._transaction():
|
|
claim = self.get_claim(claim_id)
|
|
if claim.task_id != task_id:
|
|
raise ConflictError("claim/task mismatch")
|
|
if claim.worker_identity != worker_identity:
|
|
raise ConflictError("claim identity mismatch")
|
|
if claim.status != "ACTIVE":
|
|
raise ConflictError("claim not active")
|
|
if claim.lease_until < now:
|
|
raise LockedError("lease expired")
|
|
task = self.get_task(task_id)
|
|
if task.status == "CLAIMED":
|
|
self.conn.execute(
|
|
"UPDATE tasks SET status='RUNNING', revision=revision+1, updated_at=? WHERE task_id=?",
|
|
(now, task_id),
|
|
)
|
|
self.append_event(
|
|
task_id,
|
|
"TASK_STARTED",
|
|
{"claim_id": claim_id},
|
|
subject=f"task/{task_id}",
|
|
)
|
|
elif task.status != "RUNNING":
|
|
raise ConflictError("task is not running")
|
|
self.conn.execute(
|
|
"UPDATE claims SET lease_until=? WHERE claim_id=?",
|
|
(now + max(1, int(lease_seconds)), claim_id),
|
|
)
|
|
self.append_event(
|
|
task_id,
|
|
"TASK_PROGRESS",
|
|
{
|
|
"claim_id": claim_id,
|
|
"progress": progress,
|
|
"stage": stage,
|
|
"message": message,
|
|
},
|
|
subject=f"task/{task_id}",
|
|
)
|
|
return self.get_claim(claim_id)
|
|
|
|
def recover_expired_claim(
|
|
self, task_id: str, claim_id: str, probe: ProbeEvidence
|
|
) -> TaskRecord:
|
|
"""Compatibility name retaining the typed, attested recovery contract."""
|
|
if not isinstance(probe, ProbeEvidence):
|
|
raise ValidationError("typed ProbeEvidence required")
|
|
return self.recover_claim_with_probe_and_event(task_id, claim_id, probe)
|
|
|
|
def recover_claim_with_probe_and_event(
|
|
self, task_id: str, claim_id: str, probe: ProbeEvidence
|
|
) -> TaskRecord:
|
|
now = _now()
|
|
with self._transaction():
|
|
claim = self.get_claim(claim_id)
|
|
if claim.task_id != task_id:
|
|
raise ConflictError("claim/task mismatch")
|
|
if claim.status != "ACTIVE":
|
|
raise ConflictError("claim not active")
|
|
if claim.lease_until >= now:
|
|
raise LockedError("lease not expired")
|
|
if (
|
|
probe.task_id != task_id
|
|
or probe.claim_id != claim_id
|
|
or probe.worker_id != claim.worker_id
|
|
or probe.worker_identity != claim.worker_identity
|
|
or probe.operation_digest != claim.operation_digest
|
|
):
|
|
raise ConflictError("probe binding mismatch")
|
|
if not probe.verify(
|
|
now=now, attestation_verifier=self.probe_attestation_verifier
|
|
):
|
|
from .errors import ForbiddenError
|
|
|
|
raise ForbiddenError("probe evidence invalid")
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO probe_evidence
|
|
(probe_id,task_id,claim_id,envelope_json,created_at)
|
|
VALUES(?,?,?,?,?)
|
|
""",
|
|
(
|
|
probe.probe_id,
|
|
task_id,
|
|
claim_id,
|
|
json.dumps(probe.to_dict(), ensure_ascii=False),
|
|
now,
|
|
),
|
|
)
|
|
self._fault("recover.after_probe_before_state")
|
|
current_task = self.get_task(task_id)
|
|
if current_task.status in {"CANCELLED", "COMPLETED", "FAILED_FINAL"}:
|
|
# Recovery records what happened; it cannot undo a terminal Owner
|
|
# decision or reopen a finished task. This check shares the state
|
|
# transaction with the claim close and the appended probe event.
|
|
task_status = current_task.status
|
|
claim_status = "CANCELLED" if current_task.status == "CANCELLED" else "CLOSED_BY_RECOVERY"
|
|
elif probe.outcome == ProbeOutcome.APPLIED_VERIFIED:
|
|
task_status = "COMPLETED"
|
|
claim_status = "COMPLETED"
|
|
elif probe.outcome == ProbeOutcome.CONFLICT:
|
|
task_status = "MANUAL_REVIEW_REQUIRED"
|
|
claim_status = "CONFLICT"
|
|
elif probe.outcome == ProbeOutcome.INDETERMINATE:
|
|
task_status = "FAILED_RECOVERABLE"
|
|
claim_status = "RECONCILIATION_REQUIRED"
|
|
else:
|
|
task_status = "FAILED_RECOVERABLE"
|
|
claim_status = "EXPIRED_NOT_APPLIED"
|
|
self.conn.execute(
|
|
"UPDATE claims SET status=?,completed_at=? WHERE claim_id=?",
|
|
(claim_status, now, claim_id),
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE tasks SET status=?,revision=revision+1,updated_at=?
|
|
WHERE task_id=?
|
|
""",
|
|
(task_status, now, task_id),
|
|
)
|
|
self.append_event(
|
|
task_id,
|
|
"CLAIM_RECOVERY_PROBED",
|
|
{
|
|
"claim_id": claim_id,
|
|
"probe_id": probe.probe_id,
|
|
"outcome": probe.outcome.value,
|
|
},
|
|
subject=f"task/{task_id}",
|
|
)
|
|
return self.get_task(task_id)
|
|
|
|
def task_has_retryable_probe(self, task_id: str) -> bool:
|
|
row = self.conn.execute(
|
|
"""
|
|
SELECT envelope_json FROM probe_evidence
|
|
WHERE task_id=? ORDER BY created_at DESC LIMIT 1
|
|
""",
|
|
(task_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
try:
|
|
payload = json.loads(row["envelope_json"])
|
|
return (
|
|
payload["outcome"] == ProbeOutcome.NOT_APPLIED.value
|
|
and float(payload["expires_at"]) > _now()
|
|
)
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
return False
|
|
|
|
def complete_claim(
|
|
self,
|
|
task_id: str,
|
|
claim_id: str,
|
|
task_status: str,
|
|
result_payload: dict[str, Any],
|
|
evidence_entries: list[tuple[str, dict[str, Any], str]] | None = None,
|
|
) -> WorkerClaim:
|
|
now = _now()
|
|
with self._transaction():
|
|
claim = self.get_claim(claim_id)
|
|
if claim.status != "ACTIVE":
|
|
raise ConflictError("claim already closed")
|
|
if claim.task_id != task_id:
|
|
raise ConflictError("claim/task mismatch")
|
|
if claim.lease_until < now:
|
|
raise LockedError("lease expired")
|
|
task = self.get_task(task_id)
|
|
if claim.operation_digest != task.task_plan.operation_digest():
|
|
raise ConflictError("operation digest mismatch")
|
|
task.with_status(task_status)
|
|
for kind, payload, worker_identity in evidence_entries or []:
|
|
self.add_evidence(task_id, kind, payload, worker_identity)
|
|
self.conn.execute(
|
|
"UPDATE claims SET status='COMPLETED', completed_at=?, result_payload=? WHERE claim_id=?",
|
|
(now, json.dumps(result_payload, ensure_ascii=False), claim_id),
|
|
)
|
|
self.conn.execute(
|
|
"UPDATE tasks SET status=?, revision=revision+1, updated_at=? WHERE task_id=?",
|
|
(task_status, now, task_id),
|
|
)
|
|
event_type = "TASK_COMPLETED" if task_status == "COMPLETED" else "TASK_FAILED"
|
|
self.append_event(task_id, event_type, {"claim_id": claim_id}, subject=f"task/{task_id}")
|
|
return self.get_claim(claim_id)
|
|
|
|
def complete_claim_with_evidence_and_event(
|
|
self,
|
|
task_id: str,
|
|
claim_id: str,
|
|
task_status: str,
|
|
result_payload: dict[str, Any],
|
|
evidence_envelopes: list[EvidenceEnvelope],
|
|
) -> WorkerClaim:
|
|
now = _now()
|
|
with self._transaction():
|
|
claim = self.get_claim(claim_id)
|
|
if claim.task_id != task_id:
|
|
raise ConflictError("claim/task mismatch")
|
|
if claim.status != "ACTIVE":
|
|
raise ConflictError("claim already closed")
|
|
if claim.lease_until < now:
|
|
raise LockedError("lease expired")
|
|
task = self.get_task(task_id)
|
|
if claim.operation_digest != task.task_plan.operation_digest():
|
|
raise ConflictError("operation digest mismatch")
|
|
previous = self._last_evidence_hash(task_id)
|
|
for envelope in evidence_envelopes:
|
|
if not envelope.verify(
|
|
expected_task_id=task_id,
|
|
expected_claim_id=claim_id,
|
|
expected_previous_hash=previous,
|
|
attestation_verifier=self.evidence_attestation_verifier,
|
|
):
|
|
raise ConflictError("evidence envelope invalid")
|
|
self.add_evidence_envelope(envelope)
|
|
previous = envelope.evidence_digest
|
|
self._fault("complete.after_evidence_before_state")
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE claims SET status='COMPLETED',completed_at=?,result_payload=?
|
|
WHERE claim_id=?
|
|
""",
|
|
(now, json.dumps(redact_value(result_payload), ensure_ascii=False), claim_id),
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE tasks SET status=?,revision=revision+1,updated_at=?
|
|
WHERE task_id=?
|
|
""",
|
|
(task_status, now, task_id),
|
|
)
|
|
self.append_event(
|
|
task_id,
|
|
"TASK_COMPLETED" if task_status == "COMPLETED" else "TASK_FAILED",
|
|
{"claim_id": claim_id},
|
|
subject=f"task/{task_id}",
|
|
)
|
|
return self.get_claim(claim_id)
|
|
|
|
def fail_claim(self, task_id: str, claim_id: str, task_status: str) -> WorkerClaim:
|
|
return self.complete_claim(task_id, claim_id, task_status, {"status": task_status})
|
|
|
|
def fail_claim_with_event(
|
|
self, task_id: str, claim_id: str, task_status: str, reason: str
|
|
) -> WorkerClaim:
|
|
with self._transaction():
|
|
claim = self.complete_claim(
|
|
task_id, claim_id, task_status, {"status": task_status}
|
|
)
|
|
# complete_claim emitted the single TASK_FAILED event.
|
|
self._fault("fail.after_state_before_commit")
|
|
return claim
|
|
|
|
def cancel_task(self, task_id: str, expected_revision: int, reason: str) -> TaskRecord:
|
|
return self.update_task_status(task_id, expected_revision, "CANCELLED", failure_reason=reason)
|
|
|
|
def cancel_task_with_event(
|
|
self, task_id: str, expected_revision: int, reason: str
|
|
) -> TaskRecord:
|
|
with self._transaction():
|
|
updated = self.cancel_task(task_id, expected_revision, reason)
|
|
self._fault("cancel.after_state_before_event")
|
|
self.append_event(
|
|
task_id,
|
|
"TASK_CANCELLED",
|
|
{"reason": reason},
|
|
subject=f"task/{task_id}",
|
|
)
|
|
return updated
|
|
|
|
def delegate_task_with_budget_and_events(
|
|
self,
|
|
parent_task_id: str,
|
|
child_plan: TaskPlan,
|
|
*,
|
|
caller: str,
|
|
owner_principal_id: str = "",
|
|
mission_id: str = "",
|
|
created_at: float | None = None,
|
|
) -> TaskRecord:
|
|
with self._transaction():
|
|
parent_budget = self.get_task_budget(parent_task_id)
|
|
needed = Budget()
|
|
for name in ("time_seconds", "attempts", "tokens", "cost"):
|
|
available = getattr(parent_budget, name)
|
|
requested = getattr(child_plan.budgets, name)
|
|
if name == "attempts" and requested is None:
|
|
requested = 1
|
|
if available is not None:
|
|
requested = 0 if requested is None else requested
|
|
if requested > available:
|
|
raise ConflictError(f"budget exceeded for {name}")
|
|
setattr(child_plan.budgets, name, requested)
|
|
setattr(needed, name, requested)
|
|
self.consume_budget(parent_task_id, needed)
|
|
self._fault("delegate.after_budget_before_child")
|
|
child_plan.parent_task_id = parent_task_id
|
|
child = self.create_task(
|
|
child_plan,
|
|
owner_principal_id=owner_principal_id,
|
|
mission_id=mission_id,
|
|
created_at=created_at,
|
|
)
|
|
self.append_event(
|
|
parent_task_id,
|
|
"TASK_DELEGATED",
|
|
{
|
|
"from_task_id": parent_task_id,
|
|
"to_task_id": child.task_id,
|
|
"delegated_by": caller,
|
|
},
|
|
subject=f"task/{parent_task_id}",
|
|
)
|
|
self.append_event(
|
|
child.task_id,
|
|
"TASK_CREATED",
|
|
{"task_id": child.task_id, "parent_task_id": parent_task_id},
|
|
subject=f"task/{child.task_id}",
|
|
)
|
|
return child
|
|
|
|
def append_event(
|
|
self,
|
|
task_id: str,
|
|
event_type: str,
|
|
event_data: dict[str, Any],
|
|
*,
|
|
subject: str | None = None,
|
|
) -> None:
|
|
if len(json.dumps(event_data)) > 256_000:
|
|
raise ValidationError("event data too large", code="event_payload_too_large")
|
|
with self._transaction():
|
|
task = self.get_task(task_id)
|
|
stored_data = redact_value(event_data)
|
|
task_data = self.conn.execute(
|
|
"SELECT seq, hash FROM events WHERE task_id=? ORDER BY seq DESC LIMIT 1",
|
|
(task_id,),
|
|
).fetchone()
|
|
prev_hash = task_data["hash"] if task_data else ""
|
|
seq = (task_data["seq"] + 1) if task_data else 1
|
|
event_id = f"evt-{uuid.uuid4()}"
|
|
created_at = _now()
|
|
resolved_subject = subject or f"task/{task_id}"
|
|
payload = {
|
|
"event_id": event_id,
|
|
"task_id": task_id,
|
|
"seq": seq,
|
|
"type": event_type,
|
|
"subject": resolved_subject,
|
|
"created_at": created_at,
|
|
"data": stored_data,
|
|
"prev_hash": prev_hash,
|
|
}
|
|
ev_hash = _hash_hex(_canonical(payload))
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO events(event_id, task_id, seq, type, event_data, subject, created_at, prev_hash, hash)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
event_id,
|
|
task_id,
|
|
seq,
|
|
event_type,
|
|
json.dumps(stored_data, ensure_ascii=False),
|
|
resolved_subject,
|
|
created_at,
|
|
prev_hash,
|
|
ev_hash,
|
|
),
|
|
)
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO outbox(message_id, task_id, task_status, event_type, stage, correlation_id, payload, delivery_status, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'NOT_DELIVERED', ?)
|
|
""",
|
|
(
|
|
f"msg-{uuid.uuid4()}",
|
|
task_id,
|
|
task.status,
|
|
event_type,
|
|
stored_data.get("stage"),
|
|
stored_data.get("correlation_id")
|
|
or task.task_plan.correlation_id
|
|
or task_id,
|
|
json.dumps(
|
|
{
|
|
"specversion": "1.0",
|
|
"id": event_id,
|
|
"type": event_type,
|
|
"source": stored_data.get("source", "aios.task-plane"),
|
|
"subject": subject or f"task/{task_id}",
|
|
"time": time.strftime(
|
|
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(created_at)
|
|
),
|
|
"datacontenttype": "application/json",
|
|
"data": {"task_id": task_id, "event": stored_data},
|
|
},
|
|
ensure_ascii=False,
|
|
),
|
|
_now(),
|
|
),
|
|
)
|
|
|
|
def list_events(self, task_id: str) -> list[dict[str, Any]]:
|
|
task_id = _validated_id(task_id, "task_id")
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT event_id, seq, type, event_data, subject, created_at, hash, prev_hash FROM events WHERE task_id=? ORDER BY seq ASC",
|
|
(task_id,),
|
|
).fetchall()
|
|
result: list[dict[str, Any]] = []
|
|
for r in rows:
|
|
result.append(
|
|
{
|
|
"event_id": r["event_id"],
|
|
"seq": r["seq"],
|
|
"type": r["type"],
|
|
"subject": r["subject"],
|
|
"created_at": r["created_at"],
|
|
"hash": r["hash"],
|
|
"prev_hash": r["prev_hash"],
|
|
"data": json.loads(r["event_data"]),
|
|
}
|
|
)
|
|
return result
|
|
|
|
def list_outbox(self, status: str = "NOT_DELIVERED") -> list[dict[str, Any]]:
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT * FROM outbox WHERE delivery_status=? ORDER BY created_at ASC",
|
|
(status,),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
|
|
def get_outbox_message(self, message_id: str) -> dict[str, Any]:
|
|
message_id = _validated_id(message_id, "message_id")
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT * FROM outbox WHERE message_id=?", (message_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("outbox message not found")
|
|
return dict(row)
|
|
|
|
def record_outbox_delivery(
|
|
self,
|
|
message_id: str,
|
|
*,
|
|
receipt: str | None,
|
|
error: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Only a non-empty gateway receipt can close delivery as DELIVERED."""
|
|
message_id = _validated_id(message_id, "message_id")
|
|
with self._transaction():
|
|
row = self.conn.execute(
|
|
"SELECT * FROM outbox WHERE message_id=?", (message_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
raise NotFoundError("outbox message not found")
|
|
if row["delivery_status"] == "DELIVERED":
|
|
if receipt and row["receipt"] == receipt:
|
|
return dict(row)
|
|
raise ConflictError("delivery receipt conflict")
|
|
status = "DELIVERED" if receipt else "NOT_DELIVERED"
|
|
delivered_at = _now() if receipt else None
|
|
self.conn.execute(
|
|
"""
|
|
UPDATE outbox
|
|
SET delivery_status=?, delivered_at=?, attempts=attempts+1,
|
|
error=?, receipt=?
|
|
WHERE message_id=?
|
|
""",
|
|
(status, delivered_at, error, receipt, message_id),
|
|
)
|
|
updated = self.conn.execute(
|
|
"SELECT * FROM outbox WHERE message_id=?", (message_id,)
|
|
).fetchone()
|
|
return dict(updated)
|
|
|
|
def verify_event_chain(self, task_id: str) -> bool:
|
|
events = self.list_events(task_id)
|
|
prev = ""
|
|
for event in events:
|
|
current = _hash_hex(
|
|
_canonical(
|
|
{
|
|
"event_id": event["event_id"],
|
|
"task_id": task_id,
|
|
"seq": event["seq"],
|
|
"type": event["type"],
|
|
"subject": event["subject"],
|
|
"created_at": event["created_at"],
|
|
"data": event["data"],
|
|
"prev_hash": event["prev_hash"],
|
|
}
|
|
)
|
|
)
|
|
if event["prev_hash"] != prev or event["hash"] != current:
|
|
return False
|
|
prev = event["hash"]
|
|
return True
|
|
|
|
def add_evidence(self, task_id: str, kind: str, payload: dict[str, Any], worker_identity: str) -> str:
|
|
claim = self.active_claim(task_id)
|
|
claim_id = claim.claim_id if claim else "claim-unbound-shadow"
|
|
envelope = EvidenceEnvelope.create(
|
|
task_id=task_id,
|
|
claim_id=claim_id,
|
|
kind=kind,
|
|
worker_identity=worker_identity,
|
|
payload=payload,
|
|
previous_evidence_hash=self._last_evidence_hash(task_id),
|
|
)
|
|
self.add_evidence_envelope(envelope)
|
|
return envelope.evidence_id
|
|
|
|
def _last_evidence_hash(self, task_id: str) -> str:
|
|
row = self.conn.execute(
|
|
"""
|
|
SELECT digest FROM evidence WHERE task_id=?
|
|
ORDER BY created_at DESC,evidence_id DESC LIMIT 1
|
|
""",
|
|
(task_id,),
|
|
).fetchone()
|
|
return str(row["digest"]) if row else ""
|
|
|
|
def add_evidence_envelope(self, envelope: EvidenceEnvelope) -> str:
|
|
with self._transaction():
|
|
self.get_task(envelope.task_id)
|
|
expected_previous = self._last_evidence_hash(envelope.task_id)
|
|
if not envelope.verify(
|
|
expected_task_id=envelope.task_id,
|
|
expected_claim_id=envelope.claim_id,
|
|
expected_previous_hash=expected_previous,
|
|
attestation_verifier=self.evidence_attestation_verifier,
|
|
):
|
|
raise ConflictError("evidence envelope invalid")
|
|
self.conn.execute(
|
|
"""
|
|
INSERT INTO evidence
|
|
(evidence_id,task_id,kind,payload,digest,worker_identity,created_at,
|
|
claim_id,previous_evidence_hash,envelope_json)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)
|
|
""",
|
|
(
|
|
envelope.evidence_id,
|
|
envelope.task_id,
|
|
envelope.kind,
|
|
json.dumps(envelope.payload, ensure_ascii=False),
|
|
envelope.evidence_digest,
|
|
envelope.worker_identity,
|
|
envelope.created_at,
|
|
envelope.claim_id,
|
|
envelope.previous_evidence_hash,
|
|
json.dumps(envelope.to_dict(), ensure_ascii=False),
|
|
),
|
|
)
|
|
return envelope.evidence_id
|
|
|
|
def list_evidence(self, task_id: str) -> list[dict[str, Any]]:
|
|
task_id = _validated_id(task_id, "task_id")
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT * FROM evidence WHERE task_id=? ORDER BY created_at ASC",
|
|
(task_id,),
|
|
).fetchall()
|
|
return [dict(r) for r in rows]
|
|
|
|
def verify_evidence_integrity(self, task_id: str) -> bool:
|
|
previous = ""
|
|
for evidence in self.list_evidence(task_id):
|
|
try:
|
|
raw = evidence.get("envelope_json")
|
|
if not raw:
|
|
return False
|
|
payload = json.loads(raw)
|
|
envelope = EvidenceEnvelope(
|
|
schema_version=int(payload["schema_version"]),
|
|
evidence_id=payload["evidence_id"],
|
|
task_id=payload["task_id"],
|
|
claim_id=payload["claim_id"],
|
|
kind=payload["kind"],
|
|
worker_identity=payload["worker_identity"],
|
|
payload=payload["payload"],
|
|
created_at=float(payload["created_at"]),
|
|
previous_evidence_hash=payload["previous_evidence_hash"],
|
|
evidence_digest=payload["evidence_digest"],
|
|
signature=payload.get("signature"),
|
|
attestation_ref=payload.get("attestation_ref"),
|
|
)
|
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
return False
|
|
if (
|
|
evidence["task_id"] != task_id
|
|
or evidence["digest"] != envelope.evidence_digest
|
|
or evidence["kind"] != envelope.kind
|
|
or evidence["worker_identity"] != envelope.worker_identity
|
|
or json.loads(evidence["payload"]) != envelope.payload
|
|
or not envelope.verify(
|
|
expected_task_id=task_id,
|
|
expected_claim_id=envelope.claim_id,
|
|
expected_previous_hash=previous,
|
|
attestation_verifier=self.evidence_attestation_verifier,
|
|
)
|
|
):
|
|
return False
|
|
previous = envelope.evidence_digest
|
|
return True
|
|
|
|
def list_children(self, task_id: str) -> list[str]:
|
|
task_id = _validated_id(task_id, "task_id")
|
|
with self._lock:
|
|
rows = self.conn.execute(
|
|
"SELECT child_task_id FROM delegations WHERE parent_task_id=? ORDER BY created_at ASC",
|
|
(task_id,),
|
|
).fetchall()
|
|
return [row["child_task_id"] for row in rows]
|
|
|
|
def parent_of(self, task_id: str) -> str | None:
|
|
task_id = _validated_id(task_id, "task_id")
|
|
with self._lock:
|
|
row = self.conn.execute(
|
|
"SELECT parent_task_id FROM tasks WHERE task_id=?", (task_id,)
|
|
).fetchone()
|
|
return row["parent_task_id"] if row else None
|
|
|
|
def _compute_depth(self, task_id: str) -> int:
|
|
depth = 0
|
|
visited = set()
|
|
current = task_id
|
|
while current:
|
|
if current in visited:
|
|
raise ValidationError("parent cycle detected in task chain")
|
|
visited.add(current)
|
|
parent = self.parent_of(current)
|
|
if parent is None:
|
|
return depth
|
|
depth += 1
|
|
current = parent
|
|
return depth
|