Files
dify/api/schedule/dify_builder_reconcile_task.py
T
Charles YaoandClaude Opus 4.8 7e83264330 refactor: rename Workflow Copilot → Dify Builder
Project rename across api/ + web/: modules, symbols, routes
(/console/api/dify-builder/*), feature flag (DIFY_BUILDER_ENABLED),
Celery queue/tasks, the 7 DB tables (via migration df1b0114de40),
and regenerated contract artifacts. Behavior unchanged; 430 tests green,
ruff clean. Branch renamed feat/copilot-py-backend → dify-builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 11:18:35 -07:00

75 lines
3.2 KiB
Python

"""Beat sweeper for interrupted dify-builder sessions (P3b Task 5).
``interrupted`` is already DERIVED on read (Task 3's ``get_session_view``:
``is_working(state) and not session_lock.exists(sid)``) -- the moment a dead
advance task's lock TTL expires, on-read already reports interrupted without
this sweeper. This task is therefore a backstop/notifier, not the mechanism
that sets interrupted: it finds sessions stuck in a working state with no
live advance lock past ``DIFY_BUILDER_MAX_ADVANCE_SECONDS`` of
staleness, publishes an interrupted signal to the progress bus so any live
SSE re-syncs, and logs a warning.
It must NOT mutate committed session state (that would break resume-from-
committed -- the user's retry re-runs the working handler from the committed
state) and must NOT re-enqueue (non-idempotent steps like publish/verify
require explicit user retry -- this matches the P3b idempotency deferral).
No schema change, no migration.
Runs on the default worker (no ``queue=``), like the other lightweight
``schedule/*`` beat tasks -- NOT the ``dify_builder`` queue used by
``tasks.dify_builder_advance_task``. The ``FlaskTask`` base
(``extensions.ext_celery.init_app``) already provides ``app_context``; this
module must not push its own.
"""
import logging
from datetime import timedelta
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from core.dify_builder.state import PcState, is_working
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models.dify_builder import DifyBuilderSession
from services.dify_builder import progress_bus, session_lock
logger = logging.getLogger(__name__)
__all__ = ["reconcile_interrupted_sessions"]
_WORKING_STATES = [str(s) for s in PcState if is_working(s)]
def _session_factory() -> sessionmaker:
return sessionmaker(bind=db.engine, expire_on_commit=False)
@shared_task
def reconcile_interrupted_sessions() -> int:
"""Beat sweeper: sessions stuck in a working state with no live advance
lock past the staleness threshold are interrupted (the advancing task died
before releasing the lock). Publish an interrupted signal to the progress
bus so any live SSE re-syncs, and log. Does NOT mutate committed state
(resume-from-committed) or re-enqueue (non-idempotent steps need explicit
user retry). Returns the number of sessions flagged."""
stale_before = naive_utc_now() - timedelta(seconds=dify_config.DIFY_BUILDER_MAX_ADVANCE_SECONDS)
factory = _session_factory()
with factory() as db_session:
rows = db_session.execute(
select(DifyBuilderSession.id, DifyBuilderSession.current_state)
.where(DifyBuilderSession.current_state.in_(_WORKING_STATES))
.where(DifyBuilderSession.updated_at < stale_before)
).all()
flagged = 0
for sid, state in rows:
if session_lock.exists(sid):
continue # a live advance holds the lock -- leave it alone
progress_bus.publish(sid, {"kind": "error", "error": "interrupted"})
logger.warning("Dify Builder session %s interrupted (state=%s, no live lock)", sid, state)
flagged += 1
return flagged