Files
dify/api/services/dify_builder/application/recovery.py
T

146 lines
5.8 KiB
Python

"""Structure-fingerprint recovery (C-1).
Detect a canvas hand-edit made while a dify_builder session waited, classify the
drift, and drive continue-vs-restart. Triggered by three free-string action
ids (``check_recovery`` / ``recovery_continue`` / ``recovery_restart``) that
the runner short-circuits on, exactly like ``stop`` / ``resume``.
Recovery only changes durable state. The service projects its explanation in
the existing decision card; the runner preserves user choice receipts.
"""
from core.dify_builder.contract import RecoveryClass, RecoveryRef
from core.dify_builder.models import ConversationItem, DifyBuilderContext, EntryMode, Session, Turn
from core.dify_builder.state import PcState
from services.dify_builder.application.ports import DifyPort
__all__ = [
"apply_recovery_action",
"classify",
"entry_state_for",
"recovery_ref_for",
"target_node_ids",
]
_ENTRY_STATE: dict[EntryMode, PcState] = {
EntryMode.FIX: PcState.FIX_DIAGNOSE,
EntryMode.FIX_CHECKLIST: PcState.CHECKLIST_DIAGNOSE,
EntryMode.BUILD: PcState.BUILD_CAPABILITY_CHECK,
EntryMode.EDIT: PcState.EDIT_CAPABILITY_CHECK,
}
def entry_state_for(entry_mode: EntryMode) -> PcState:
"""The state a fresh session of this mode starts in. Mirrors
``service.create_*_session``; a parity test guards drift."""
return _ENTRY_STATE[entry_mode]
def recovery_ref_for(recovery_class: str) -> RecoveryRef | None:
"""Build the SessionView recovery offer from a stored class string.
``None`` when there is no active recovery (``recovery_class == ""``).
can_continue is false only for a structural-invalidating drift; can_restart
is false only when nothing changed."""
if not recovery_class:
return None
return RecoveryRef(
recovery_class=recovery_class,
can_continue=recovery_class != RecoveryClass.STRUCTURAL_INVALIDATING,
can_restart=recovery_class != RecoveryClass.UNCHANGED,
)
def target_node_ids(fc: DifyBuilderContext, entry_mode: EntryMode) -> list[str]:
"""The node ids this task is about -- used to tell a structural edit that
LEAVES the targets intact (compatible) from one that removes them
(invalidating)."""
if entry_mode == EntryMode.BUILD:
return list(fc.built_node_ids)
if entry_mode == EntryMode.EDIT:
return list(fc.edit_target_node_ids)
ids: list[str] = []
if entry_mode == EntryMode.FIX_CHECKLIST:
ids = [e.node_id for e in fc.checklist_errors if e.node_id]
if fc.diagnosis is not None and fc.diagnosis.culprit_node_id and fc.diagnosis.culprit_node_id not in ids:
ids.append(fc.diagnosis.culprit_node_id)
return ids
def classify(
cur_hash: str,
cur_fingerprint: str,
present_node_ids: list[str],
fc: DifyBuilderContext,
entry_mode: EntryMode,
) -> RecoveryClass:
"""Four-way drift classification (C-1)."""
if cur_hash == fc.last_snapshot_hash:
return RecoveryClass.UNCHANGED
if cur_fingerprint == fc.last_structure_fingerprint:
return RecoveryClass.CONFIG_ONLY
if set(target_node_ids(fc, entry_mode)) <= set(present_node_ids):
return RecoveryClass.STRUCTURAL_COMPATIBLE
return RecoveryClass.STRUCTURAL_INVALIDATING
def _reset_working_fields(fc: DifyBuilderContext) -> None:
"""Clear per-flow working state for a clean re-entry. Preserves the
conversation, next_seq, and the mode's durable inputs (goal_text,
requirements, edit_rules, checklist_errors, failed_run_id, source)."""
fc.diagnosis = None
fc.staged_repair = []
# Same-task recovery retains native failure/graph convergence evidence.
fc.risk = None
fc.change_set = None
fc.checkpoint_id = ""
fc.checkpoint_seq = 0
fc.verify_run_id = ""
fc.test_input_ref = ""
fc.effect_confirmation = {}
fc.effect_confirmation_form = {}
fc.plan_items = []
fc.plan_title = ""
fc.plan_input_summary = ""
fc.plan_output_summary = ""
fc.plan_version_tag = ""
fc.resource_selection = {}
fc.built_node_ids = []
fc.edit_target_node_ids = []
# The engine's refusal of a batch that is no longer staged: quoting it at
# the next proposal would send the agent after a problem that is gone.
fc.last_edit_rejection = ""
fc.last_snapshot_hash = ""
fc.last_structure_fingerprint = ""
fc.paused = False
def apply_recovery_action(
dify: DifyPort, turn: Turn, s: Session, fc: DifyBuilderContext
) -> tuple[PcState, list[ConversationItem]]:
"""Handle check_recovery / recovery_continue / recovery_restart. Returns
``(next_state, items)`` for the runner to commit in a single CAS step.
The runner guarantees ``turn.action.kind`` is one of the three."""
kind = turn.action.kind if turn.action is not None else ""
if kind == "check_recovery":
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
cur_fp = dify.structural_fingerprint(graph)
present = dify.graph_node_ids(graph)
cls = classify(cur_hash, cur_fp, present, fc, s.entry_mode)
fc.recovery_class = str(cls)
return s.current_state, []
if kind == "recovery_continue":
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
fc.last_snapshot_hash = cur_hash
fc.last_structure_fingerprint = dify.structural_fingerprint(graph)
fc.recovery_class = ""
return s.current_state, []
if kind == "recovery_restart":
fc.task_control["generation"] = int(fc.task_control.get("generation", 0)) + 1
fc.recovery_class = ""
_reset_working_fields(fc)
graph, cur_hash = dify.read_graph(s.app_id, turn.actor)
fc.last_snapshot_hash = cur_hash
fc.last_structure_fingerprint = dify.structural_fingerprint(graph)
return entry_state_for(s.entry_mode), []
return s.current_state, [] # defensive: unreachable given the runner's guard