Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

436 lines
16 KiB
Python

"""Reconciler tick: sweep stuck rows and escalate to terminal status + alert."""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy import Connection
from application.api.user.idempotency import MAX_TASK_ATTEMPTS
from application.core.settings import settings
from application.storage.db.engine import get_engine
from application.storage.db.repositories.pending_tool_state import (
PendingToolStateRepository,
)
from application.storage.db.repositories.reconciliation import (
ReconciliationRepository,
)
from application.storage.db.repositories.stack_logs import StackLogsRepository
if TYPE_CHECKING:
from application.storage.db.repositories.schedules import SchedulesRepository
logger = logging.getLogger(__name__)
MAX_MESSAGE_RECONCILE_ATTEMPTS = 3
def run_reconciliation() -> Dict[str, Any]:
"""Single tick of the reconciler. Five sweeps, FOR UPDATE SKIP LOCKED.
Stuck ``executed`` tool calls always flip to ``failed`` — operators
handle cleanup manually via the structured alert. The side effect is
assumed to have committed; no automated rollback is attempted.
Stuck ``task_dedup`` rows (lease expired AND attempts >= max)
promote to ``failed`` so a same-key retry can re-claim instead of
sitting in ``pending`` until 24 h TTL.
"""
if not settings.POSTGRES_URI:
return {
"messages_failed": 0,
"tool_calls_failed": 0,
"skipped": "POSTGRES_URI not set",
}
engine = get_engine()
summary = {
"messages_failed": 0,
"tool_calls_failed": 0,
"ingests_stalled": 0,
"idempotency_pending_failed": 0,
"schedule_runs_failed": 0,
}
# User-facing events to fan out once their DB writes have committed
# (publish-after-commit). Each item is
# ``(user_id, event_type, payload, scope)``.
events: list[tuple] = []
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
pt_repo = PendingToolStateRepository(conn)
for msg in repo.find_and_lock_stuck_messages():
new_count = repo.increment_message_reconcile_attempts(msg["id"])
if new_count >= MAX_MESSAGE_RECONCILE_ATTEMPTS:
repo.mark_message_failed(
msg["id"],
error=(
"reconciler: stuck in pending/streaming for >5 min "
f"after {new_count} attempts"
),
)
summary["messages_failed"] += 1
# Revoke any awaiting-approval prompt: the resumable state
# dies with the message, so the durable
# ``tool.approval.required`` envelope must be cleared or the
# UI toast lingers on reconnect. Only emit when a row was
# actually deleted so non-approval failures stay quiet.
user_id = msg.get("user_id")
conversation_id = msg.get("conversation_id")
if (
user_id
and conversation_id
and pt_repo.delete_state(str(conversation_id), str(user_id))
):
events.append(
(
str(user_id),
"tool.approval.cleared",
{
"conversation_id": str(conversation_id),
"message_id": str(msg["id"]),
"reason": "failed",
},
{"kind": "conversation", "id": str(conversation_id)},
)
)
_emit_alert(
conn,
name="reconciler_message_failed",
user_id=msg.get("user_id"),
detail={
"message_id": str(msg["id"]),
"attempts": new_count,
},
)
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_proposed_tool_calls():
repo.mark_tool_call_failed(
row["call_id"],
error=(
"reconciler: stuck in 'proposed' for >5 min; "
"side effect status unknown"
),
)
summary["tool_calls_failed"] += 1
_emit_alert(
conn,
name="reconciler_tool_call_failed_proposed",
user_id=None,
detail={
"call_id": row["call_id"],
"tool_name": row.get("tool_name"),
},
)
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_executed_tool_calls():
repo.mark_tool_call_failed(
row["call_id"],
error=(
"reconciler: executed-not-confirmed; side effect "
"assumed committed, manual cleanup required"
),
)
summary["tool_calls_failed"] += 1
_emit_alert(
conn,
name="reconciler_tool_call_failed_executed",
user_id=None,
detail={
"call_id": row["call_id"],
"tool_name": row.get("tool_name"),
"action_name": row.get("action_name"),
},
)
# Q4: ingest checkpoints whose heartbeat has gone silent. Each is
# escalated to terminal ``status='stalled'`` and alerted once — no
# worker kill, no rollback of the partial embed. The 'stalled' flag
# ends the re-alert loop and drives the "indexing failed" badge the
# sources list derives from this row.
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_and_lock_stalled_ingests():
summary["ingests_stalled"] += 1
_emit_alert(
conn,
name="reconciler_ingest_stalled",
user_id=row.get("user_id"),
detail={
"source_id": str(row.get("source_id")),
"embedded_chunks": row.get("embedded_chunks"),
"total_chunks": row.get("total_chunks"),
"last_updated": str(row.get("last_updated")),
},
)
repo.mark_ingest_stalled(str(row["source_id"]))
# Tell the upload toast the ingest is done-for. Without it the
# toast spins on "Training…" for the whole live session; only
# ``source.ingest.failed`` flips it to a terminal error.
user_id = row.get("user_id")
source_id = row.get("source_id")
if user_id and source_id:
events.append(
(
str(user_id),
"source.ingest.failed",
{
"source_id": str(source_id),
"filename": row.get("source_name") or "",
"operation": "upload",
"error": "Indexing stopped after a stall.",
},
{"kind": "source", "id": str(source_id)},
)
)
# Q5: idempotency rows whose lease expired with attempts exhausted.
# The wrapper's poison-loop guard normally finalises these, but if
# the wrapper itself died mid-task (worker SIGKILL, OOM during
# heartbeat) the row sits in ``pending`` blocking same-key retries
# via ``_lookup_completed`` returning None for the whole 24 h TTL.
# Promote to ``failed`` so a retry can re-claim and either resume
# or fail loudly.
with engine.begin() as conn:
repo = ReconciliationRepository(conn)
for row in repo.find_stuck_idempotency_pending(
max_attempts=MAX_TASK_ATTEMPTS,
):
error_msg = (
"reconciler: idempotency lease expired with attempts "
f"({row['attempt_count']}) >= {MAX_TASK_ATTEMPTS}; "
"task abandoned"
)
repo.mark_idempotency_pending_failed(
row["idempotency_key"], error=error_msg,
)
summary["idempotency_pending_failed"] += 1
_emit_alert(
conn,
name="reconciler_idempotency_pending_failed",
user_id=None,
detail={
"idempotency_key": row["idempotency_key"],
"task_name": row.get("task_name"),
"task_id": row.get("task_id"),
"attempts": row.get("attempt_count"),
},
)
# Q6: scheduler runs stuck in 'running' past the soft-time-limit window.
from application.storage.db.repositories.schedule_runs import (
ScheduleRunsRepository,
)
from application.storage.db.repositories.schedules import SchedulesRepository
from application.core.settings import settings as _settings
stuck_age = max(
15, int(_settings.SCHEDULE_RUN_TIMEOUT // 60) + 5,
)
with engine.begin() as conn:
runs_repo = ScheduleRunsRepository(conn)
schedules_repo = SchedulesRepository(conn)
for run in runs_repo.list_stuck_running(age_minutes=stuck_age):
runs_repo.update(
run["id"],
{
"status": "timeout",
"finished_at": datetime.now(timezone.utc),
"error_type": "timeout",
"error": (
"reconciler: schedule_run stuck in 'running' past "
f"{stuck_age} min"
),
},
)
schedules_repo.bump_failure_count(str(run["schedule_id"]))
flipped = _terminal_flip_once_schedule(
schedules_repo, str(run["schedule_id"]),
)
summary["schedule_runs_failed"] += 1
events.extend(
_schedule_terminal_events(
run,
error_type="timeout",
error=(
"reconciler: schedule_run stuck in 'running' past "
f"{stuck_age} min"
),
once_completed=flipped,
)
)
_emit_alert(
conn,
name="reconciler_schedule_run_timeout",
user_id=run.get("user_id"),
detail={
"run_id": str(run["id"]),
"schedule_id": str(run["schedule_id"]),
},
)
# Q7: scheduler runs orphaned in 'pending' — dispatcher committed but
# apply_async failed (broker outage / crash mid-dispatch).
with engine.begin() as conn:
runs_repo = ScheduleRunsRepository(conn)
schedules_repo = SchedulesRepository(conn)
for run in runs_repo.list_stuck_pending(age_minutes=stuck_age):
runs_repo.update(
run["id"],
{
"status": "failed",
"finished_at": datetime.now(timezone.utc),
"error_type": "internal",
"error": (
"reconciler: schedule_run stuck in 'pending' past "
f"{stuck_age} min (worker_never_started)"
),
},
)
schedules_repo.bump_failure_count(str(run["schedule_id"]))
flipped = _terminal_flip_once_schedule(
schedules_repo, str(run["schedule_id"]),
)
summary["schedule_runs_failed"] += 1
events.extend(
_schedule_terminal_events(
run,
error_type="internal",
error=(
"reconciler: schedule_run stuck in 'pending' past "
f"{stuck_age} min (worker_never_started)"
),
once_completed=flipped,
)
)
_emit_alert(
conn,
name="reconciler_schedule_run_pending",
user_id=run.get("user_id"),
detail={
"run_id": str(run["id"]),
"schedule_id": str(run["schedule_id"]),
},
)
_publish_events(events)
return summary
def _terminal_flip_once_schedule(
schedules_repo: "SchedulesRepository", schedule_id: str,
) -> bool:
"""Flip a once-schedule to 'completed' after its run terminates.
Recurring schedules keep firing; once-schedules would otherwise read
'active forever' since next_run_at is already NULL. Returns ``True``
when the flip happened so the caller can publish a status event.
"""
schedule = schedules_repo.get_internal(schedule_id)
if schedule is None or schedule.get("trigger_type") != "once":
return False
if schedule.get("status") in {"completed", "cancelled"}:
return False
schedules_repo.update_internal(
schedule_id, {"status": "completed", "next_run_at": None},
)
return True
def _schedule_terminal_events(
run: Dict[str, Any],
*,
error_type: str,
error: str,
once_completed: bool,
) -> list:
"""Build the user-facing events for a reconciler-failed schedule run.
Always a ``schedule.run.failed`` (so a watching run-log updates live),
plus ``schedule.completed`` when a once-schedule flipped terminal — the
UI otherwise shows it 'active' with stale Edit/Cancel actions until a
manual refresh.
"""
user_id = run.get("user_id")
schedule_id = run.get("schedule_id")
if not user_id or not schedule_id:
return []
scope = {"kind": "schedule", "id": str(schedule_id)}
out: list = [
(
str(user_id),
"schedule.run.failed",
{
"run_id": str(run["id"]),
"schedule_id": str(schedule_id),
"status": "failed",
"error_type": error_type,
"error": error,
},
scope,
)
]
if once_completed:
out.append(
(
str(user_id),
"schedule.completed",
{"schedule_id": str(schedule_id), "status": "completed"},
scope,
)
)
return out
def _publish_events(events: list) -> None:
"""Fan out user-facing events after their DB writes have committed.
Each item is ``(user_id, event_type, payload, scope)``. Best-effort:
a publish miss is swallowed per event so one failure can't strand the
rest, and notifications never surface as a reconciler-task failure.
"""
if not events:
return
from application.events.publisher import publish_user_event
for user_id, event_type, payload, scope in events:
try:
publish_user_event(user_id, event_type, payload, scope=scope)
except Exception:
logger.exception(
"reconciler: failed to publish %s for user=%s",
event_type,
user_id,
)
def _emit_alert(
conn: Connection,
*,
name: str,
user_id: Optional[str],
detail: Dict[str, Any],
) -> None:
"""Structured ``logger.error`` plus a ``stack_logs`` row for operators."""
extra = {"alert": name, **detail}
logger.error("reconciler alert: %s", name, extra=extra)
try:
StackLogsRepository(conn).insert(
activity_id=str(uuid.uuid4()),
endpoint="reconciliation_worker",
level="ERROR",
user_id=user_id,
query=name,
stacks=[extra],
)
except Exception:
logger.exception("reconciler: failed to write stack_logs row for %s", name)