mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
969 lines
39 KiB
Python
969 lines
39 KiB
Python
"""Interaction-owned recovery admission, durable transitions and mutation fences.
|
|
|
|
The existing confirmed command owner alone may initiate replacement allocation.
|
|
SQL readiness never grants execution until SDK and refreshed wait ACKs are durable.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Literal, Self
|
|
|
|
from dify_agent.protocol import CreateExecutionBindingRequest, CreateExecutionBindingResponse
|
|
from dify_agent.protocol.confirmed_recovery import AgentRecoveryBinding, AgentRecoverySuccessor
|
|
from pydantic import Field, ValidationError, model_validator
|
|
from sqlalchemy.orm import Session
|
|
|
|
from models.dify_builder_native_tests import NativeTestInvocation
|
|
from runtime_contracts.builder.execution import BuilderExecutionPolicyError, Digest, canonical_digest
|
|
from runtime_contracts.builder.native.agent_contracts import AgentParentNodeRef, AgentWaitAssociation
|
|
from runtime_contracts.builder.native.approval_contracts import NativeApprovalProposal
|
|
from runtime_contracts.builder.native.contracts import (
|
|
ID,
|
|
CheckpointRef,
|
|
Counter,
|
|
EffectIntent,
|
|
FiniteJSON,
|
|
FrozenDTO,
|
|
NativeTestContextRef,
|
|
OperationKey,
|
|
operation_digest,
|
|
)
|
|
from services.dify_builder.native_testing.agent_recovery_state import RECOVERY_RECORD_KEY, AgentRecoveryRecord
|
|
|
|
if TYPE_CHECKING:
|
|
from dify_agent.protocol.confirmed_recovery import InstallConfirmedRecoverySuccessorReceipt
|
|
from dify_agent.protocol.native_continuation import AgentCheckpoint
|
|
|
|
from runtime_contracts.builder.native.agent_contracts import (
|
|
AgentNodeAssociation,
|
|
AgentPreparedConfig,
|
|
AgentTestWorkspaceRef,
|
|
)
|
|
from runtime_contracts.builder.native.runtime_contracts import NativeCheckpointPayload, ResolvedNativeTestContext
|
|
from services.dify_builder.native_testing.state_store import ObjectReceipt
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ConfirmedSnapshotIdentity:
|
|
"""Original executable identity captured before this worker reads events."""
|
|
|
|
parent_node: AgentParentNodeRef
|
|
parent_generation: int
|
|
agent_run_id: str
|
|
agent_generation: int
|
|
binding: AgentRecoveryBinding
|
|
recovery: "RecoveryPendingState | None" = None
|
|
|
|
|
|
class RecoveryAllocationReceipt(FrozenDTO):
|
|
"""Protected exact ordinary request/response; never an authorization grant."""
|
|
|
|
attempt_id: Digest
|
|
request: CreateExecutionBindingRequest
|
|
response: CreateExecutionBindingResponse
|
|
|
|
|
|
class RecoveryPreparedConfigPayload(FrozenDTO):
|
|
"""Immutable executable config bytes; receipt identity is assigned by its owner.
|
|
|
|
No self-referencing object ID is encoded. Readers wrap acknowledged receipt.id
|
|
and canonical executable digest in the unchanged SDK prepared-config DTO.
|
|
"""
|
|
|
|
schema_version: Literal["agent-recovery-config-v1"]
|
|
parent_node: AgentParentNodeRef
|
|
config: FiniteJSON
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PreparedRecoverySelection:
|
|
"""Acknowledged immutable graph supplied by the later 3C producer."""
|
|
|
|
expected: AgentRecoveryRecord
|
|
relation: AgentRecoverySuccessor
|
|
relation_receipt: "ObjectReceipt"
|
|
config_receipt: "ObjectReceipt"
|
|
association_receipt: "ObjectReceipt"
|
|
checkpoint_receipt: "ObjectReceipt"
|
|
reconstruction_receipt: "ObjectReceipt"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SelectedRecovery:
|
|
"""Decoded immutable graph; current SQL selection is rechecked after all I/O."""
|
|
|
|
record: AgentRecoveryRecord
|
|
relation: AgentRecoverySuccessor
|
|
config: "AgentPreparedConfig"
|
|
association: "AgentNodeAssociation"
|
|
checkpoint: "AgentCheckpoint"
|
|
predecessor_config: "AgentPreparedConfig"
|
|
predecessor_association: "AgentNodeAssociation"
|
|
predecessor_wait: "AgentWaitAssociation"
|
|
predecessor_checkpoint: "AgentCheckpoint"
|
|
receipts: tuple["ObjectReceipt", ...]
|
|
|
|
|
|
class RecoveryStagedPending(FrozenDTO):
|
|
"""Server-produced inert pending mapping; consumers recheck SQL ownership."""
|
|
|
|
predecessor_pending_id: ID
|
|
pending_id: ID
|
|
intent: "EffectIntent"
|
|
proposal: "NativeApprovalProposal"
|
|
|
|
|
|
class RecoveryApprovalEntry(FrozenDTO):
|
|
"""One fresh pending action and its freshly assessed disclosure."""
|
|
|
|
predecessor_pending_id: ID
|
|
pending_id: ID
|
|
intent: EffectIntent
|
|
proposal: NativeApprovalProposal
|
|
|
|
|
|
class RecoveryPendingSelection(FrozenDTO):
|
|
"""One immutable selected overlay; the parent checkpoint remains unchanged."""
|
|
|
|
schema_version: Literal["agent-recovery-pending-v1"]
|
|
attempt_id: Digest
|
|
original_checkpoint: CheckpointRef
|
|
parent_generation: Counter
|
|
relation_ref: ID
|
|
relation_digest: Digest
|
|
sdk_receipt_ref: ID
|
|
wait: AgentWaitAssociation
|
|
approvals: tuple[RecoveryApprovalEntry, ...] = Field(min_length=1, max_length=128)
|
|
|
|
@model_validator(mode="after")
|
|
def exact_entries(self) -> Self:
|
|
if (
|
|
len({e.pending_id for e in self.approvals}) != len(self.approvals)
|
|
or len({e.predecessor_pending_id for e in self.approvals}) != len(self.approvals)
|
|
or any(e.pending_id == e.predecessor_pending_id or e.intent.key != e.proposal.key for e in self.approvals)
|
|
):
|
|
raise ValueError("invalid recovery approvals")
|
|
return self
|
|
|
|
|
|
class RecoveryPendingProof(FrozenDTO):
|
|
"""Decoded overlay and its actual protected-object receipt identity."""
|
|
|
|
object_ref: ID
|
|
value_digest: Digest
|
|
selection: RecoveryPendingSelection
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RecoveryPendingState:
|
|
"""Per-call protected graph and receipt evidence, never a cached authority."""
|
|
|
|
proof: RecoveryPendingProof
|
|
graph: SelectedRecovery
|
|
sdk_receipt: "InstallConfirmedRecoverySuccessorReceipt"
|
|
sdk_object: "ObjectReceipt"
|
|
wait_object: "ObjectReceipt"
|
|
original_object: "ObjectReceipt"
|
|
original_payload: "NativeCheckpointPayload"
|
|
snapshot_objects: tuple["ObjectReceipt", ...]
|
|
context: "ResolvedNativeTestContext"
|
|
workspace: "AgentTestWorkspaceRef"
|
|
resource_digest: str
|
|
|
|
|
|
def validate_allocation_receipt(
|
|
*,
|
|
expected: AgentRecoveryRecord,
|
|
receipt: RecoveryAllocationReceipt,
|
|
tenant_id: str,
|
|
agent_id: str,
|
|
home_snapshot_ref: str | None,
|
|
) -> None:
|
|
"""Require one reserved ordinary request and fresh physical refs, without I/O.
|
|
|
|
The owning caller reconstructs tenant/Agent/Home from protected SQL state.
|
|
A response proves refs for that request only, not server idempotence.
|
|
"""
|
|
request, response = receipt.request, receipt.response
|
|
if (
|
|
(
|
|
receipt.attempt_id,
|
|
request.tenant_id,
|
|
request.agent_id,
|
|
request.binding_id,
|
|
request.workspace_id,
|
|
request.existing_workspace_ref,
|
|
request.home_snapshot_ref,
|
|
request.native_test_scope,
|
|
request.native_scope_reconcile,
|
|
)
|
|
!= (
|
|
expected.attempt_id,
|
|
tenant_id,
|
|
agent_id,
|
|
expected.reserved_binding_id,
|
|
expected.reserved_workspace_id,
|
|
None,
|
|
home_snapshot_ref,
|
|
None,
|
|
False,
|
|
)
|
|
or response.binding_ref == expected.predecessor.binding_ref
|
|
or response.workspace_ref == expected.predecessor.workspace_ref
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_allocation_mismatch")
|
|
|
|
|
|
def recovery_record(bindings: dict) -> AgentRecoveryRecord | None:
|
|
"""Parse the private SQL pointer, refusing corruption without external I/O.
|
|
|
|
Absence means the invocation has never reserved recovery. A malformed present
|
|
pointer fails closed and cannot be treated as the generation-zero case.
|
|
"""
|
|
if RECOVERY_RECORD_KEY not in bindings:
|
|
return None
|
|
try:
|
|
return AgentRecoveryRecord.model_validate(bindings[RECOVERY_RECORD_KEY])
|
|
except ValidationError:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_unready") from None
|
|
|
|
|
|
def require_recovery_mutation(*, bindings: dict, binding_id: str, workspace_generation: int) -> None:
|
|
"""Fence a mutation using the root writer's current binding selection.
|
|
|
|
Preconditions: bindings were read by the existing owned root writer; the
|
|
identity comes from the protected config/intent, never caller classification.
|
|
Success grants no permit: existing approval and dispatch checks still apply.
|
|
All partial handshakes and predecessor writes fail without side effects.
|
|
"""
|
|
record = recovery_record(bindings)
|
|
if record is None:
|
|
return
|
|
if record.phase != "ready" or record.sdk_receipt_ref is None or record.successor_wait_ref is None:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_unready")
|
|
if (binding_id, workspace_generation) != (record.reserved_binding_id, record.next_workspace_generation):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_identity_mismatch")
|
|
|
|
|
|
def _locked_invocation(*, session: Session, tenant_id: str, ref: NativeTestContextRef) -> NativeTestInvocation:
|
|
"""Lock and refresh one exact active invocation inside the caller's root transaction.
|
|
|
|
Missing/inactive/foreign rows refuse before mutation. The caller owns commit
|
|
or rollback; no network, storage or permission I/O occurs here.
|
|
"""
|
|
from services.dify_builder.native_testing.state_store import owner_query
|
|
|
|
invocation = session.scalar(
|
|
owner_query(NativeTestInvocation, ref, tenant_id)
|
|
.where(NativeTestInvocation.active.is_(True))
|
|
.with_for_update()
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
if invocation is None:
|
|
raise BuilderExecutionPolicyError("native_invocation_mismatch")
|
|
return invocation
|
|
|
|
|
|
def reserve_recovery_attempt(
|
|
*, session: Session, tenant_id: str, ref: NativeTestContextRef, proposed: AgentRecoveryRecord
|
|
) -> tuple[AgentRecoveryRecord, bool]:
|
|
"""Coalesce one exact predecessor under the existing root writer transaction.
|
|
|
|
The caller must first prove eligibility and acknowledge protected evidence in
|
|
this same transaction. No allocation or I/O occurs here. The row lock adds a
|
|
final CAS guard; the existing request/root lock remains the global owner.
|
|
Different interaction keys preserve the first reserved IDs and digest. A
|
|
changed immutable input conflicts rather than silently selecting an attempt.
|
|
"""
|
|
if proposed.phase != "reserved" or proposed.identity.invocation_id != ref.invocation_id:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
invocation = _locked_invocation(session=session, tenant_id=tenant_id, ref=ref)
|
|
current = recovery_record(invocation.bindings)
|
|
if current is not None:
|
|
immutable_fields = {
|
|
"identity",
|
|
"attempt_id",
|
|
"predecessor",
|
|
"predecessor_checkpoint",
|
|
"parent_generation",
|
|
"agent_generation",
|
|
"next_workspace_generation",
|
|
"input_digest",
|
|
"definition_digest",
|
|
"home_snapshot_id",
|
|
"home_snapshot_digest",
|
|
}
|
|
if current.model_dump(include=immutable_fields) != proposed.model_dump(include=immutable_fields):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
return current, False
|
|
invocation.bindings = {**invocation.bindings, RECOVERY_RECORD_KEY: proposed.model_dump(mode="json")}
|
|
return proposed, True
|
|
|
|
|
|
def claim_recovery_allocation(
|
|
*, session: Session, tenant_id: str, ref: NativeTestContextRef, expected: AgentRecoveryRecord
|
|
) -> AgentRecoveryRecord | None:
|
|
"""Return the sole reserved->creating winner, or None without new authority.
|
|
|
|
The caller commits before invoking backend create. Creating has no lease and
|
|
cannot be reclaimed even after a crash before network I/O. Duplicate callers
|
|
and unknown attempts never receive physical allocation authority.
|
|
"""
|
|
invocation = _locked_invocation(session=session, tenant_id=tenant_id, ref=ref)
|
|
current = recovery_record(invocation.bindings)
|
|
if current is None or current.attempt_id != expected.attempt_id:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
if current.phase != "reserved":
|
|
return None
|
|
if current != expected:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
creating = AgentRecoveryRecord.model_validate({**current.model_dump(), "phase": "creating"})
|
|
invocation.bindings = {**invocation.bindings, RECOVERY_RECORD_KEY: creating.model_dump(mode="json")}
|
|
return creating
|
|
|
|
|
|
def require_recovery_operation(*, bindings: dict, key: OperationKey) -> None:
|
|
"""Fence prepare/claim with the generation embedded by the trusted cursor owner.
|
|
|
|
Absence of a recovery record preserves legacy keys. Recovery requires the exact fresh
|
|
occurrence prefix and completed two-store handshake; this is an additional
|
|
constraint on existing binding, permit and dispatch validation.
|
|
"""
|
|
record = recovery_record(bindings)
|
|
if record is None:
|
|
return
|
|
require_recovery_mutation(
|
|
bindings=bindings, binding_id=record.reserved_binding_id, workspace_generation=record.next_workspace_generation
|
|
)
|
|
if key.occurrence_path[:3] != (
|
|
record.identity.parent_execution_id,
|
|
"recovery",
|
|
str(record.next_workspace_generation),
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_identity_mismatch")
|
|
|
|
|
|
def settle_recovery_allocation(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
ref: NativeTestContextRef,
|
|
expected: AgentRecoveryRecord,
|
|
allocation_receipt_ref: str,
|
|
) -> AgentRecoveryRecord:
|
|
"""Retain a confirmed incurred allocation even after authority is revoked.
|
|
|
|
Caller holds the existing settlement root lock and acknowledges exact bytes
|
|
in this transaction. This writes evidence only, never selects a successor.
|
|
Exact replay succeeds; changed evidence or a different attempt refuses.
|
|
"""
|
|
from services.dify_builder.native_testing.agent_recovery_state import advance_recovery_record
|
|
from services.dify_builder.native_testing.state_store import owner_query
|
|
|
|
invocation = session.scalar(
|
|
owner_query(NativeTestInvocation, ref, tenant_id).with_for_update().execution_options(populate_existing=True)
|
|
)
|
|
if invocation is None or expected.phase != "creating":
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
current = recovery_record(invocation.bindings)
|
|
proposed = AgentRecoveryRecord.model_validate(
|
|
{**expected.model_dump(), "phase": "allocated", "allocation_receipt_ref": allocation_receipt_ref}
|
|
)
|
|
if current is not None and current == proposed:
|
|
return current
|
|
if current is None or current != expected:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
proposed = advance_recovery_record(current, proposed)
|
|
invocation.bindings = {**invocation.bindings, RECOVERY_RECORD_KEY: proposed.model_dump(mode="json")}
|
|
return proposed
|
|
|
|
|
|
def write_confirmed_snapshot(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
bindings: dict,
|
|
identity: ConfirmedSnapshotIdentity,
|
|
current: ConfirmedSnapshotIdentity,
|
|
serialized_snapshot: str,
|
|
) -> None:
|
|
"""Write only the original complete tuple in the caller's root transaction.
|
|
|
|
Caller reconstructs current from protected objects and checks full SQL owner,
|
|
pin, execution, scope and generation chain. No read/write crosses this lock.
|
|
Recovery reservation and snapshot write therefore have one ordering owner.
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
from models.agent import AgentWorkingResourceStatus, AgentWorkspaceBinding
|
|
|
|
invocation = _locked_invocation(session=session, tenant_id=tenant_id, ref=identity.parent_node.ref)
|
|
require_recovery_mutation(
|
|
bindings=invocation.bindings,
|
|
binding_id=identity.binding.binding_id,
|
|
workspace_generation=identity.binding.workspace_generation,
|
|
)
|
|
require_recovery_mutation(
|
|
bindings=bindings,
|
|
binding_id=identity.binding.binding_id,
|
|
workspace_generation=identity.binding.workspace_generation,
|
|
)
|
|
if identity != current:
|
|
raise BuilderExecutionPolicyError("native_agent_snapshot_identity_mismatch")
|
|
binding = session.scalar(
|
|
select(AgentWorkspaceBinding)
|
|
.where(
|
|
AgentWorkspaceBinding.id == identity.binding.binding_id,
|
|
AgentWorkspaceBinding.tenant_id == tenant_id,
|
|
AgentWorkspaceBinding.workspace_id == identity.binding.workspace_id,
|
|
AgentWorkspaceBinding.backend_binding_ref == identity.binding.binding_ref,
|
|
AgentWorkspaceBinding.status == AgentWorkingResourceStatus.ACTIVE,
|
|
)
|
|
.with_for_update()
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
if binding is None:
|
|
raise BuilderExecutionPolicyError("native_agent_snapshot_identity_mismatch")
|
|
binding.session_snapshot = serialized_snapshot
|
|
|
|
|
|
def finalize_recovery_selection(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
ref: NativeTestContextRef,
|
|
selection: PreparedRecoverySelection,
|
|
session_scope,
|
|
) -> AgentRecoveryRecord:
|
|
"""Atomically select an acknowledged graph and replace one exclusive SQL participant.
|
|
|
|
Caller validates protected bytes/access/pause/pins before this root transaction.
|
|
Exact receipt readback and immutable attempt CAS occur here. SQL-ready grants
|
|
no execution; rollback retains the earlier durable allocation ACK.
|
|
"""
|
|
from dataclasses import asdict
|
|
|
|
from dify_agent.protocol.confirmed_recovery import recovery_successor_digest
|
|
|
|
from core.workflow.nodes.agent_v2.session_store import WorkflowAgentWorkspaceStore
|
|
from models.agent import AgentWorkspace
|
|
from models.dify_builder_native_tests import NativePayloadObject
|
|
from models.workflow import WorkflowNodeExecutionModel
|
|
from runtime_contracts.builder.native.agent_contracts import (
|
|
AgentTestWorkspaceRef,
|
|
)
|
|
from services.agent.workspace_service import AgentWorkspaceService
|
|
from services.dify_builder.native_testing.agent_confirmed_workspace import ALLOCATION
|
|
from services.dify_builder.native_testing.agent_recovery_state import advance_recovery_record
|
|
from services.dify_builder.native_testing.state_store import ObjectReceipt, owner_query
|
|
|
|
invocation = _locked_invocation(session=session, tenant_id=tenant_id, ref=ref)
|
|
expected, relation = selection.expected, selection.relation
|
|
# The original immutable node-input owner binds the complete parent, including
|
|
# its result-object ID. A matching digest alone cannot adopt another input ref.
|
|
input_row = session.scalar(
|
|
owner_query(NativePayloadObject, ref, tenant_id).where(
|
|
NativePayloadObject.kind == "result",
|
|
NativePayloadObject.logical_key == "node-input:" + expected.identity.parent_execution_id,
|
|
)
|
|
)
|
|
if input_row is None or (input_row.id, input_row.value_digest) != (
|
|
relation.parent_node.input_result_ref,
|
|
expected.input_digest,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
if expected.phase != "allocated" or (
|
|
relation.attempt_id,
|
|
relation.parent_node.ref.model_dump(mode="json"),
|
|
relation.parent_node.execution_id,
|
|
relation.parent_node.input_digest,
|
|
relation.expected_parent_generation,
|
|
relation.expected_agent_generation,
|
|
relation.predecessor,
|
|
relation.predecessor_checkpoint,
|
|
relation.successor.binding_id,
|
|
relation.successor.workspace_id,
|
|
relation.successor.workspace_generation,
|
|
relation.successor.config_ref,
|
|
relation.successor.association_object_ref,
|
|
relation.reconstruction_object_ref,
|
|
relation.successor_checkpoint.checkpoint_id,
|
|
) != (
|
|
expected.attempt_id,
|
|
ref.model_dump(mode="json"),
|
|
expected.identity.parent_execution_id,
|
|
expected.input_digest,
|
|
expected.parent_generation,
|
|
expected.agent_generation,
|
|
expected.predecessor,
|
|
expected.predecessor_checkpoint,
|
|
expected.reserved_binding_id,
|
|
expected.reserved_workspace_id,
|
|
expected.next_workspace_generation,
|
|
selection.config_receipt.id,
|
|
selection.association_receipt.id,
|
|
selection.reconstruction_receipt.id,
|
|
selection.checkpoint_receipt.id,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
for role, receipt in (
|
|
("relation", selection.relation_receipt),
|
|
("config", selection.config_receipt),
|
|
("association", selection.association_receipt),
|
|
("checkpoint", selection.checkpoint_receipt),
|
|
("reconstruction", selection.reconstruction_receipt),
|
|
):
|
|
if (receipt.tenant_id, receipt.request_id, receipt.invocation_id, receipt.kind, receipt.logical_key) != (
|
|
tenant_id,
|
|
ref.request_id,
|
|
ref.invocation_id,
|
|
"result",
|
|
"agent-recovery:" + expected.attempt_id + ":" + role,
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
row = session.scalar(
|
|
owner_query(NativePayloadObject, ref, tenant_id).where(NativePayloadObject.id == receipt.id)
|
|
)
|
|
if row is None or ObjectReceipt.from_payload_object(row) != receipt:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
digest = recovery_successor_digest(relation)
|
|
if selection.relation_receipt.value_digest != digest or (
|
|
selection.checkpoint_receipt.value_digest,
|
|
selection.reconstruction_receipt.value_digest,
|
|
) != (relation.successor_checkpoint.payload_digest, relation.reconstruction_digest):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
ready = advance_recovery_record(
|
|
expected,
|
|
AgentRecoveryRecord.model_validate(
|
|
{
|
|
**expected.model_dump(),
|
|
"phase": "ready",
|
|
"successor_object_ref": selection.relation_receipt.id,
|
|
"successor_digest": digest,
|
|
}
|
|
),
|
|
)
|
|
current = recovery_record(invocation.bindings)
|
|
if current is not None and current.phase == "ready":
|
|
try:
|
|
advance_recovery_record(ready, current)
|
|
except ValueError:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict") from None
|
|
binding = AgentWorkspaceService.get_active_binding(
|
|
session=session,
|
|
tenant_id=tenant_id,
|
|
binding_id=relation.successor.binding_id,
|
|
expected_owner_scope=session_scope.workspace_owner,
|
|
)
|
|
workspace = session.get(AgentWorkspace, relation.successor.workspace_id)
|
|
execution = session.get(WorkflowNodeExecutionModel, session_scope.node_execution_id)
|
|
if (
|
|
binding is None
|
|
or workspace is None
|
|
or execution is None
|
|
or (
|
|
binding.workspace_id,
|
|
binding.backend_binding_ref,
|
|
binding.agent_id,
|
|
binding.agent_config_version_id,
|
|
binding.base_home_snapshot_id,
|
|
workspace.backend_workspace_ref,
|
|
execution.agent_workspace_binding_id,
|
|
)
|
|
!= (
|
|
relation.successor.workspace_id,
|
|
relation.successor.binding_ref,
|
|
session_scope.agent_id,
|
|
session_scope.agent_config_snapshot_id,
|
|
expected.home_snapshot_id,
|
|
relation.successor.workspace_ref,
|
|
relation.successor.binding_id,
|
|
)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
return current
|
|
if current != expected:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
AgentWorkspaceService.finalize_confirmed_recovery_binding(
|
|
session=session,
|
|
scope=session_scope.workspace_owner,
|
|
predecessor=relation.predecessor,
|
|
successor=relation.successor,
|
|
agent_id=session_scope.agent_id,
|
|
pinned_snapshot_id=session_scope.agent_config_snapshot_id,
|
|
home_snapshot_id=expected.home_snapshot_id,
|
|
)
|
|
WorkflowAgentWorkspaceStore.rebind_confirmed_node_execution(
|
|
session=session,
|
|
scope=session_scope,
|
|
predecessor_binding_id=relation.predecessor.binding_id,
|
|
successor_binding_id=relation.successor.binding_id,
|
|
)
|
|
workspace = AgentTestWorkspaceRef(
|
|
binding_id=relation.successor.binding_id,
|
|
workspace_id=relation.successor.workspace_id,
|
|
backend_binding_ref=relation.successor.binding_ref,
|
|
backend_workspace_ref=relation.successor.workspace_ref,
|
|
pinned_snapshot_id=session_scope.agent_config_snapshot_id,
|
|
home_snapshot_id=expected.home_snapshot_id,
|
|
workspace_generation=relation.successor.workspace_generation,
|
|
)
|
|
invocation.bindings = {
|
|
**invocation.bindings,
|
|
RECOVERY_RECORD_KEY: ready.model_dump(mode="json"),
|
|
ALLOCATION: {"scope": asdict(session_scope), "workspace": workspace.model_dump(mode="json")},
|
|
}
|
|
return ready
|
|
|
|
|
|
def require_never_dispatched_pending(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
ref: NativeTestContextRef,
|
|
pending_ids: tuple[str, ...],
|
|
generation: int,
|
|
) -> None:
|
|
"""Prove exact pending ownership and absence of spent attempts/physical claims.
|
|
|
|
A pending label alone is insufficient. Call under the same root lock as
|
|
reservation/finalization so dispatch cannot race this zero-effect proof.
|
|
Historical completed operations remain untouched.
|
|
"""
|
|
from models.dify_builder_native_tests import (
|
|
NativeEffectAttempt,
|
|
NativeEffectDispatchClaim,
|
|
NativeEffectOperation,
|
|
NativePendingAction,
|
|
)
|
|
from services.dify_builder.native_testing.state_store import owner_query
|
|
|
|
pending = list(
|
|
session.scalars(
|
|
owner_query(NativePendingAction, ref, tenant_id).where(
|
|
NativePendingAction.id.in_(pending_ids),
|
|
NativePendingAction.generation == generation,
|
|
NativePendingAction.category == "approval",
|
|
)
|
|
)
|
|
)
|
|
if not pending_ids or len(set(pending_ids)) != len(pending_ids) or {row.id for row in pending} != set(pending_ids):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_dispatched")
|
|
operation_ids = {row.operation_id for row in pending}
|
|
operations = list(
|
|
session.scalars(
|
|
owner_query(NativeEffectOperation, ref, tenant_id).where(NativeEffectOperation.id.in_(operation_ids))
|
|
)
|
|
)
|
|
if len(operations) != len(pending) or any(
|
|
row.state != "pending" or row.result_ref is not None for row in operations
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_dispatched")
|
|
for model in (NativeEffectAttempt, NativeEffectDispatchClaim):
|
|
if (
|
|
session.scalar(owner_query(model, ref, tenant_id).where(model.operation_id.in_(operation_ids)).limit(1))
|
|
is not None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_dispatched")
|
|
|
|
|
|
def waiting_confirmed_checkpoint(*, session, ref, request, owned, scope):
|
|
"""Existing waiting-owner proof shared by retention and inert recovery primitives.
|
|
|
|
SQL only; current durable approval state, pins, owner and complete in-flight
|
|
evidence are checked in the caller's transaction. No physical work occurs.
|
|
"""
|
|
from datetime import UTC
|
|
|
|
from sqlalchemy import select
|
|
|
|
from models.dify_builder import DifyBuilderExecutionRequest
|
|
from models.dify_builder_native_tests import (
|
|
NativeContinuation,
|
|
NativeDispatch,
|
|
NativeEffectDispatchClaim,
|
|
NativeEffectOperation,
|
|
NativeEffectOutcome,
|
|
NativeInvocationState,
|
|
NativeTestInvocation,
|
|
)
|
|
from runtime_contracts.builder.native.contracts import CheckpointRef
|
|
from services.dify_builder.native_testing.repository import utc_now
|
|
from services.dify_builder.native_testing.state_store import invocation_state, owner_query
|
|
|
|
state = invocation_state(session, ref, owned.tenant_id)
|
|
invocation = session.scalar(owner_query(NativeTestInvocation, ref, owned.tenant_id))
|
|
expires_at = scope.expires_at
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=UTC)
|
|
current = session.scalar(
|
|
select(DifyBuilderExecutionRequest.id)
|
|
.where(
|
|
DifyBuilderExecutionRequest.tenant_id == owned.tenant_id,
|
|
DifyBuilderExecutionRequest.actor_id == owned.actor_id,
|
|
DifyBuilderExecutionRequest.session_id == owned.session_id,
|
|
DifyBuilderExecutionRequest.test_input_id == owned.test_input_id,
|
|
DifyBuilderExecutionRequest.context["policy_version"].as_string() == ref.policy_version,
|
|
)
|
|
.order_by(DifyBuilderExecutionRequest.created_at.desc(), DifyBuilderExecutionRequest.id.desc())
|
|
.limit(1)
|
|
)
|
|
if (
|
|
current != request.id
|
|
or owned.state != "waiting"
|
|
or not scope.task_approved
|
|
or scope.cancelled
|
|
or expires_at <= utc_now()
|
|
or state.parent_invocation_id is not None
|
|
or state.state != "waiting"
|
|
or state.wait_reason not in {"approval", "configuration", "interaction"}
|
|
or not state.pending_ids
|
|
or state.cancellation_key is not None
|
|
or state.outcome is not None
|
|
or invocation is None
|
|
or not invocation.active
|
|
or "_agent_confirmed_workspace_v1" not in invocation.bindings
|
|
):
|
|
raise BuilderExecutionPolicyError("native_state_conflict")
|
|
for model, condition in (
|
|
(NativeEffectOperation, NativeEffectOperation.state.in_(("claimed", "unknown"))),
|
|
(NativeEffectOutcome, NativeEffectOutcome.outcome == "unknown"),
|
|
(NativeDispatch, NativeDispatch.state.in_(("queued", "published", "claimed", "unknown"))),
|
|
(NativeInvocationState, NativeInvocationState.state == "unknown"),
|
|
):
|
|
if (
|
|
session.scalar(
|
|
select(model.id)
|
|
.where(model.tenant_id == owned.tenant_id, model.request_id == ref.request_id, condition)
|
|
.limit(1)
|
|
)
|
|
is not None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_state_conflict")
|
|
# A physical claim without a durable completed/failed outcome is in flight.
|
|
settled = (
|
|
select(NativeEffectOutcome.id)
|
|
.where(
|
|
NativeEffectOutcome.tenant_id == owned.tenant_id,
|
|
NativeEffectOutcome.request_id == ref.request_id,
|
|
NativeEffectOutcome.attempt_id == NativeEffectDispatchClaim.attempt_id,
|
|
NativeEffectOutcome.operation_id == NativeEffectDispatchClaim.operation_id,
|
|
NativeEffectOutcome.outcome.in_(("completed", "failed")),
|
|
)
|
|
.exists()
|
|
)
|
|
if (
|
|
session.scalar(
|
|
select(NativeEffectDispatchClaim.id)
|
|
.where(
|
|
NativeEffectDispatchClaim.tenant_id == owned.tenant_id,
|
|
NativeEffectDispatchClaim.request_id == ref.request_id,
|
|
~settled,
|
|
)
|
|
.limit(1)
|
|
)
|
|
is not None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_state_conflict")
|
|
continuation = session.scalar(
|
|
owner_query(NativeContinuation, ref, owned.tenant_id).where(
|
|
NativeContinuation.checkpoint_id == state.checkpoint_id,
|
|
NativeContinuation.generation == state.generation,
|
|
)
|
|
)
|
|
if continuation is None:
|
|
raise BuilderExecutionPolicyError("checkpoint_unavailable")
|
|
return CheckpointRef(
|
|
checkpoint_id=continuation.checkpoint_id, payload_digest=continuation.payload_digest
|
|
), state.generation
|
|
|
|
|
|
def block_recovery_allocation_record(
|
|
*,
|
|
session: Session,
|
|
tenant_id: str,
|
|
ref: NativeTestContextRef,
|
|
expected: AgentRecoveryRecord,
|
|
) -> AgentRecoveryRecord:
|
|
"""Settle known drift on one exact confirmed allocation without granting authority.
|
|
|
|
Caller holds the settlement root lock after proving authority/pin/scope or
|
|
reconstruction drift. Only allocated→blocked is admitted. Unknown/creating,
|
|
ready and conflicting attempts refuse; transient SQL failure remains retryable.
|
|
"""
|
|
from services.dify_builder.native_testing.agent_recovery_state import advance_recovery_record
|
|
from services.dify_builder.native_testing.state_store import owner_query
|
|
|
|
invocation = session.scalar(
|
|
owner_query(NativeTestInvocation, ref, tenant_id).with_for_update().execution_options(populate_existing=True)
|
|
)
|
|
if invocation is None or expected.phase != "allocated" or expected.allocation_receipt_ref is None:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
current = recovery_record(invocation.bindings)
|
|
blocked = advance_recovery_record(
|
|
expected, AgentRecoveryRecord.model_validate({**expected.model_dump(), "phase": "blocked"})
|
|
)
|
|
if current == blocked:
|
|
return blocked
|
|
if current != expected:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
invocation.bindings = {**invocation.bindings, RECOVERY_RECORD_KEY: blocked.model_dump(mode="json")}
|
|
return blocked
|
|
|
|
|
|
def recheck_staged_recovery_pending(*, session, owned, expected, pending) -> None:
|
|
"""SQL-only proof that fresh approval rows are inert and exactly bound.
|
|
|
|
Caller holds the root lock and has read the actual protected snapshots.
|
|
Current old pending selection, fresh key namespace/binding/scope, unchanged
|
|
old ownership and zero fresh attempts/grants are mandatory. No mutation.
|
|
"""
|
|
from sqlalchemy import select
|
|
|
|
from models.dify_builder_native_tests import NativeEffectGrant, NativeEffectOperation, NativePendingAction
|
|
from services.dify_builder.native_testing.authorization import _scope_digest, validate_binding
|
|
from services.dify_builder.native_testing.repository import NativeBinding
|
|
from services.dify_builder.native_testing.state_store import invocation_state, owner_query
|
|
|
|
state = invocation_state(session, owned.ref, owned.tenant_id)
|
|
if (
|
|
recovery_record(owned.bindings) != expected
|
|
or expected.phase not in {"allocated", "ready"}
|
|
or expected.successor_wait_ref is not None
|
|
or (
|
|
set(state.pending_ids) != {entry.predecessor_pending_id for entry in pending}
|
|
or any(entry.pending_id in state.pending_ids for entry in pending)
|
|
)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
for entry in pending:
|
|
if entry.proposal.key != entry.intent.key or entry.intent.key.occurrence_path[:3] != (
|
|
expected.identity.parent_execution_id,
|
|
"recovery",
|
|
str(expected.next_workspace_generation),
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
raw_binding = owned.bindings.get(operation_digest(entry.intent.key))
|
|
if raw_binding is None:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
binding = NativeBinding.model_validate(raw_binding)
|
|
validate_binding(entry.intent, binding)
|
|
row = session.scalar(
|
|
owner_query(NativePendingAction, owned.ref, owned.tenant_id).where(
|
|
NativePendingAction.id == entry.pending_id
|
|
)
|
|
)
|
|
operation = (
|
|
None
|
|
if row is None
|
|
else session.scalar(
|
|
owner_query(NativeEffectOperation, owned.ref, owned.tenant_id).where(
|
|
NativeEffectOperation.id == row.operation_id
|
|
)
|
|
)
|
|
)
|
|
scoped = _scope_digest(entry.intent, binding)
|
|
if (
|
|
row is None
|
|
or operation is None
|
|
or (
|
|
row.reference,
|
|
row.reference_digest,
|
|
row.owner_ref,
|
|
row.owner_version,
|
|
operation.operation_digest,
|
|
operation.intent_digest,
|
|
operation.scope_digest,
|
|
)
|
|
!= (
|
|
operation.id,
|
|
canonical_digest(operation.id),
|
|
owned.app_id,
|
|
scoped,
|
|
operation_digest(entry.intent.key),
|
|
canonical_digest(entry.intent.model_dump(mode="json")),
|
|
scoped,
|
|
)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
old_path = (expected.identity.parent_execution_id, *entry.intent.key.occurrence_path[-2:])
|
|
if expected.predecessor.workspace_generation > 0:
|
|
old_path = (
|
|
expected.identity.parent_execution_id,
|
|
"recovery",
|
|
str(expected.predecessor.workspace_generation),
|
|
*entry.intent.key.occurrence_path[-2:],
|
|
)
|
|
old_key = OperationKey.model_validate({**entry.intent.key.model_dump(mode="json"), "occurrence_path": old_path})
|
|
old_binding_value = owned.bindings.get(operation_digest(old_key))
|
|
if old_binding_value is None:
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
old_binding = NativeBinding.model_validate(old_binding_value)
|
|
if old_binding.model_dump(exclude={"intent_digest", "invocation_digest"}) != binding.model_dump(
|
|
exclude={"intent_digest", "invocation_digest"}
|
|
):
|
|
raise BuilderExecutionPolicyError("native_binding_drift")
|
|
old_intent = EffectIntent.model_validate(
|
|
{
|
|
**entry.intent.model_dump(mode="json"),
|
|
"key": old_key.model_dump(mode="json"),
|
|
"capability_digest": old_binding.capability_digest,
|
|
}
|
|
)
|
|
validate_binding(old_intent, old_binding)
|
|
old_pending = session.scalar(
|
|
owner_query(NativePendingAction, owned.ref, owned.tenant_id).where(
|
|
NativePendingAction.id == entry.predecessor_pending_id
|
|
)
|
|
)
|
|
old_operation = (
|
|
None
|
|
if old_pending is None
|
|
else session.scalar(
|
|
owner_query(NativeEffectOperation, owned.ref, owned.tenant_id).where(
|
|
NativeEffectOperation.id == old_pending.operation_id
|
|
)
|
|
)
|
|
)
|
|
old_scope = _scope_digest(old_intent, old_binding)
|
|
if (
|
|
old_pending is None
|
|
or old_operation is None
|
|
or (
|
|
old_operation.operation_digest,
|
|
old_operation.intent_digest,
|
|
old_operation.scope_digest,
|
|
old_pending.reference,
|
|
old_pending.reference_digest,
|
|
old_pending.owner_ref,
|
|
old_pending.owner_version,
|
|
)
|
|
!= (
|
|
operation_digest(old_key),
|
|
old_binding.intent_digest,
|
|
old_scope,
|
|
old_operation.id,
|
|
canonical_digest(old_operation.id),
|
|
owned.app_id,
|
|
old_scope,
|
|
)
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_conflict")
|
|
require_never_dispatched_pending(
|
|
session=session,
|
|
tenant_id=owned.tenant_id,
|
|
ref=owned.ref,
|
|
pending_ids=(entry.pending_id,),
|
|
generation=expected.parent_generation,
|
|
)
|
|
if (
|
|
session.scalar(
|
|
select(NativeEffectGrant.id).where(
|
|
NativeEffectGrant.tenant_id == owned.tenant_id,
|
|
NativeEffectGrant.request_id == owned.ref.request_id,
|
|
NativeEffectGrant.scope_digest == scoped,
|
|
)
|
|
)
|
|
is not None
|
|
):
|
|
raise BuilderExecutionPolicyError("native_agent_recovery_dispatched")
|