Files
ComfyAssets-ComfyUI_PromptM…/utils/execution_context_capture.py
T

462 lines
17 KiB
Python

"""Revocable, bounded runtime evidence owned by an actual dequeue attempt."""
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass, replace
import threading
from typing import Callable, TYPE_CHECKING
from uuid import uuid4
import weakref
from .execution_context_budget import ContextBudget, PayloadLease, SealedContext
from .execution_context_graph import (
GraphProjection,
GraphRole,
SourceRef,
project_contexts,
)
from .execution_contexts import (
ContextBundle,
PromptSnapshot,
RecipeContext,
TextEvidence,
bundle_payload_size,
text_size,
MAX_ATTEMPT_BYTES,
)
from .execution_history_events import RunStart, encode_command
if TYPE_CHECKING:
from .execution_history_writer import HistoryWriterStatus
@dataclass(frozen=True)
class AttemptCapability:
nonce: str
run_id: str
session_id: str
queue_item_id: int
job_id: str
storage_epoch: str
capture_generation: int
sources: tuple[SourceRef, ...]
@dataclass
class _Attempt:
capability: AttemptCapability
projection: GraphProjection
lease: PayloadLease | None
samples: dict
retained_bytes: int
reason: str = ""
executor_identity: int | None = None
executor_rejected: bool = False
_binding: ContextVar[tuple | None] = ContextVar("prompt_manager_attempt", default=None)
def active_capture() -> tuple[ContextCapture, AttemptCapability] | None:
binding = _binding.get()
if binding is not None:
manager = binding[0]()
if manager is not None and manager.valid(binding[1]):
return manager, binding[1]
return None
def unbind_current() -> None:
binding = _binding.get()
_binding.set(None)
if binding is not None:
manager = binding[0]()
if manager is not None:
manager.revoke(binding[1])
def _unknown(reason: str = "missing") -> PromptSnapshot:
field = TextEvidence(None, "unknown", "unknown", reason)
return PromptSnapshot(field, field, field, field)
def _snapshot_bytes(snapshot: PromptSnapshot) -> int:
# Reuse the scalar validator, including provenance and per-field bounds.
bundle = ContextBundle(
(RecipeContext("x", snapshot, None, "inferred"),), "partial", "partial"
)
return bundle_payload_size(bundle)
def _bounded_snapshot(snapshot: PromptSnapshot) -> tuple[PromptSnapshot, bool]:
if type(snapshot) is not PromptSnapshot:
raise ValueError("invalid_snapshot")
fields, limited = [], False
for field in (snapshot.raw, snapshot.effective, snapshot.prepend, snapshot.append):
if type(field) is not TextEvidence:
raise ValueError("invalid_field")
if field.value is not None:
try:
text_size(field.value)
except ValueError:
field = _unknown("limit_reached").raw
limited = True
fields.append(field)
if fields[1].value is not None and fields[1].provenance != "recorded":
fields[1] = _unknown("missing").effective
bounded = PromptSnapshot(*fields)
_snapshot_bytes(bounded)
return bounded, limited
class ContextCapture:
def __init__(
self,
queue_identity: int,
status_reader: Callable[[], HistoryWriterStatus],
budget: ContextBudget,
) -> None:
self.queue_identity = queue_identity
self._status_reader = status_reader
self._budget = budget
self._lock = threading.RLock()
self._attempts: dict[str, _Attempt] = {}
def _current(self, capability: AttemptCapability) -> _Attempt | None:
attempt = self._attempts.get(capability.nonce)
if attempt is None or attempt.capability is not capability:
return None
status = self._status_reader()
if (
not status.accepting
or status.epoch != capability.storage_epoch
or status.capture_generation != capability.capture_generation
):
self._drop(capability)
return None
return attempt
def valid(self, capability: AttemptCapability) -> bool:
with self._lock:
return self._current(capability) is not None
def begin(
self, start: RunStart, generation: int, graph: object, outputs: object
) -> AttemptCapability:
unbind_current()
encode_command(start)
with self._lock:
cap = AttemptCapability(
str(uuid4()),
start.run_id,
start.session_id,
start.queue_item_id,
start.job_id,
start.storage_epoch,
generation,
(),
)
if len(self._attempts) >= 128:
return cap
lease = self._budget.reserve(MAX_ATTEMPT_BYTES)
projection = GraphProjection((), (), "")
reason = "budget_exhausted" if lease is None else ""
retained = 0
if lease is not None:
try:
projection = project_contexts(graph, outputs)
retained = (
sum(
_snapshot_bytes(role.inferred) if role.inferred else 0
for recipe in projection.recipes
for role in (recipe.positive, recipe.negative)
)
+ sum(
_snapshot_bytes(snapshot)
for _, snapshot in projection.source_snapshots
)
+ sum(
text_size(s.node_id, 512) + text_size(s.node_type, 128) + 32
for s in projection.sources
)
)
if retained > MAX_ATTEMPT_BYTES:
raise ValueError("projection_limit")
except ValueError:
projection = GraphProjection((), (), "")
reason = "graph_limit"
retained = 0
except Exception:
projection = GraphProjection((), (), "")
reason = "invalid_evidence"
retained = 0
lease.release()
lease = None
cap = replace(cap, sources=projection.sources)
self._attempts[cap.nonce] = _Attempt(
cap, projection, lease, {}, retained, reason
)
_binding.set((weakref.ref(self), cap))
self._current(cap)
return cap
def bind_executor(self, capability: AttemptCapability, identity: int) -> None:
"""Bind one scalar host identity; recursive starts invalidate cache proof."""
with self._lock:
attempt = self._current(capability)
if attempt is not None:
if attempt.executor_identity is not None:
attempt.executor_rejected = True
else:
attempt.executor_identity = identity
def matches_executor(self, capability: AttemptCapability, identity: int) -> bool:
with self._lock:
attempt = self._current(capability)
return bool(
attempt is not None
and not attempt.executor_rejected
and attempt.executor_identity == identity
)
def source_snapshot(
self, capability: AttemptCapability, source: SourceRef
) -> PromptSnapshot | None:
with self._lock:
attempt = self._current(capability)
if attempt is not None:
for ref, snapshot in attempt.projection.source_snapshots:
if ref == source:
return snapshot
return None
def observe(
self,
capability: AttemptCapability,
source: SourceRef,
invocation_index: int,
snapshot: PromptSnapshot,
) -> None:
try:
with self._lock:
attempt = self._current(capability)
if (
attempt is None
or attempt.lease is None
or source not in capability.sources
or type(source) is not SourceRef
or type(invocation_index) is not int
or not 0 <= invocation_index <= 2**63 - 1
):
return
snapshot, limited = _bounded_snapshot(snapshot)
size = _snapshot_bytes(snapshot)
if (
limited
or any(
field.reason == "limit_reached"
for field in (
snapshot.raw,
snapshot.effective,
snapshot.prepend,
snapshot.append,
)
)
) and attempt.reason not in ("multiple_samples", "limit_reached"):
attempt.reason = "field_limit"
key = source, invocation_index
if key in attempt.samples:
if attempt.samples[key] != snapshot:
attempt.reason = "multiple_samples"
return
if len(attempt.samples) >= 64:
attempt.reason = "multiple_samples"
return
if attempt.retained_bytes + size > MAX_ATTEMPT_BYTES:
attempt.reason = "limit_reached"
return
attempt.samples[key] = snapshot
attempt.retained_bytes += size
except (ValueError, AttributeError, TypeError):
return
@staticmethod
def _discard_payload(attempt: _Attempt) -> None:
attempt.projection = GraphProjection((), (), "")
attempt.samples.clear()
attempt.retained_bytes = 0
def _drop(self, capability: AttemptCapability) -> _Attempt | None:
attempt = self._attempts.get(capability.nonce)
if attempt is not None and attempt.capability is capability:
self._attempts.pop(capability.nonce)
self._discard_payload(attempt)
if attempt.lease is not None:
attempt.lease.release()
return attempt
return None
def revoke(self, capability: AttemptCapability) -> None:
with self._lock:
self._drop(capability)
def revoke_all(self) -> None:
with self._lock:
for attempt in self._attempts.values():
self._discard_payload(attempt)
if attempt.lease is not None:
attempt.lease.release()
self._attempts.clear()
def seal(self, capability: AttemptCapability) -> SealedContext:
with self._lock:
attempt = self._current(capability)
if attempt is None:
return SealedContext(None, None, "unavailable", "unavailable")
self._attempts.pop(capability.nonce)
lease = attempt.lease
if lease is None:
return SealedContext(None, None, "unavailable", attempt.reason)
try:
ambiguous = attempt.reason == "multiple_samples"
def role_snapshot(role: GraphRole) -> PromptSnapshot:
nonlocal ambiguous
if role.reason == "source-transformed":
return _unknown("unsupported")
if not role.sources:
return role.inferred or _unknown("unsupported")
values = [
snapshot
for (source, _), snapshot in attempt.samples.items()
if source in role.sources
]
if len(values) > 1 or attempt.reason == "multiple_samples":
ambiguous = True
return _unknown("ambiguous")
if len(role.sources) == 1 and len(values) == 1:
return values[0]
inferred = role.inferred
if len(role.sources) == 1:
snapshots = attempt.projection.source_snapshots
inferred = next(
(
snapshot
for source, snapshot in snapshots
if source == role.sources[0]
),
inferred,
)
if inferred is not None:
s = inferred
return PromptSnapshot(
s.raw, _unknown().effective, s.prepend, s.append
)
return _unknown()
contexts = tuple(
RecipeContext(
r.anchor_id,
role_snapshot(r.positive),
role_snapshot(r.negative),
"inferred",
)
for r in attempt.projection.recipes
)
supported = any(
role.sources or role.inferred is not None
for r in attempt.projection.recipes
for role in (r.positive, r.negative)
)
complete = bool(contexts) and all(
field.value is not None
for r in contexts
for s in (r.positive, r.negative)
for field in (s.raw, s.effective, s.prepend, s.append)
)
state = (
"ambiguous" if ambiguous else "complete" if complete else "partial"
)
reason = (
"multiple_samples"
if ambiguous
else "captured" if complete else "partial"
)
if attempt.reason == "field_limit" and not ambiguous:
state, reason = "partial", "field_limit"
if not supported:
contexts, state, reason = (), "unavailable", "no_supported_context"
if attempt.reason in ("limit_reached", "graph_limit"):
contexts, state, reason = (), "unavailable", attempt.reason
bundle = ContextBundle(contexts, state, reason)
size = bundle_payload_size(bundle)
if size + attempt.retained_bytes > MAX_ATTEMPT_BYTES:
raise ValueError("assembly_limit")
self._discard_payload(attempt)
lease.shrink_to(size)
return SealedContext(
bundle, lease, state, reason, capability.capture_generation
)
except ValueError:
self._discard_payload(attempt)
lease.release()
return SealedContext(None, None, "unavailable", "limit_reached")
except Exception:
self._discard_payload(attempt)
lease.release()
return SealedContext(None, None, "unavailable", "invalid_evidence")
def record_runtime_context(
node_type: str,
raw: str,
prepend: str,
append: str,
effective: str,
*,
role: str | None = None,
) -> None:
"""Record only a successful node return in a trusted, bound host invocation."""
try:
binding = active_capture()
if binding is None:
return
# Optional host import is independent of our package namespace.
from comfy_execution.utils import get_executing_context
host = get_executing_context()
manager, cap = binding
if (
host is None
or type(host.prompt_id) is not str
or host.prompt_id != cap.job_id
):
return
if type(host.node_id) is not str or type(host.list_index) is not int:
return
if node_type == "PromptManagerPair":
slots = (
(0, 2) if role == "positive" else (1, 3) if role == "negative" else ()
)
else:
slots = (
(0,)
if node_type == "PromptManagerText"
else (0, 1) if role is None else ()
)
for slot in slots:
source = SourceRef(host.node_id, node_type, slot)
if source not in cap.sources:
continue
snapshot = PromptSnapshot(
*(
TextEvidence(value, "recorded", "runtime_return", None)
for value in (raw, effective, prepend, append)
)
)
manager.observe(cap, source, host.list_index, snapshot)
except Exception:
# Capture must never change the host result or expose input/exception text.
return