Files
OpenOPC/scripts/reset_stuck_task.py
T
2026-07-01 17:56:31 +08:00

296 lines
10 KiB
Python

"""Safely recover a stuck OpenOPC task/session when the UI becomes unresponsive.
Typical symptoms it fixes:
- The Stop button does nothing and you cannot send new messages.
- A task is stuck in `running` with a dangling execution_lock after a
disconnect or server crash.
- A `runtime_sessions` row remains in status=running after the client
left, blocking fresh activity on the same task.
Usage:
# Inspect: show what looks stuck in a project (no changes applied)
python scripts/reset_stuck_task.py --project new10
# Inspect a specific session or task
python scripts/reset_stuck_task.py --project new10 --session app06
python scripts/reset_stuck_task.py --project new10 --task-id 5d8ee32e-...
# Actually apply the reset
python scripts/reset_stuck_task.py --project new10 --session app06 --apply
# Project-wide recovery after a crash
python scripts/reset_stuck_task.py --project new10 --all --apply
Exit codes: 0 on success / clean inspection, 2 on usage error, 3 if the
project database cannot be opened.
IMPORTANT: the OpenOPC server keeps in-memory state (asyncio locks,
session->task maps, background tasks). If the server process is still
running against the same DB, also restart it or reload the browser tab
after this script prints the "next steps" block. DB edits alone will not
release an orphaned asyncio.Lock in a live server.
"""
from __future__ import annotations
import argparse
import os
import sqlite3
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
def _opc_home() -> Path:
env = os.environ.get("OPC_HOME")
if env:
return Path(env)
here = Path(__file__).resolve().parent
for candidate in (here, *here.parents):
if (candidate / "pyproject.toml").exists() or (candidate / ".opc").exists():
return candidate / ".opc"
return Path.cwd() / ".opc"
def _project_db_path(project_id: str) -> Path:
if project_id == "__global__":
return _opc_home() / "global.db"
return _opc_home() / "projects" / project_id / "tasks.db"
@dataclass
class StuckRow:
kind: str
identifier: str
status: str
extra: str = ""
def render(self) -> str:
base = f" [{self.kind}] {self.identifier} status={self.status}"
return f"{base} {self.extra}" if self.extra else base
def _utc_now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat()
def _resolve_targets(
conn: sqlite3.Connection,
*,
task_id: str | None,
session_id_or_title: str | None,
include_all: bool,
) -> tuple[set[str], set[str]]:
"""Return (task_ids, session_ids) that match user-provided filters."""
task_ids: set[str] = set()
session_ids: set[str] = set()
if task_id:
task_ids.add(task_id)
row = conn.execute(
"SELECT session_id FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if row and row[0]:
session_ids.add(row[0])
if session_id_or_title:
rows = conn.execute(
"SELECT session_id FROM sessions WHERE session_id = ? OR title = ?",
(session_id_or_title, session_id_or_title),
).fetchall()
for (sid,) in rows:
session_ids.add(sid)
if not rows:
session_ids.add(session_id_or_title)
for sid in list(session_ids):
for (tid,) in conn.execute(
"SELECT id FROM tasks WHERE session_id = ?", (sid,)
).fetchall():
task_ids.add(tid)
if include_all:
task_ids.update(
tid for (tid,) in conn.execute(
"SELECT id FROM tasks WHERE status = 'running' OR execution_lock = 1"
).fetchall()
)
session_ids.update(
sid for (sid,) in conn.execute(
"SELECT DISTINCT session_id FROM runtime_sessions WHERE status = 'running'"
).fetchall() if sid
)
return task_ids, session_ids
def _scan_stuck(
conn: sqlite3.Connection,
task_ids: set[str],
session_ids: set[str],
) -> list[StuckRow]:
findings: list[StuckRow] = []
if task_ids:
placeholders = ",".join("?" * len(task_ids))
for (tid, status, lock, locked_at, title) in conn.execute(
f"SELECT id, status, execution_lock, execution_locked_at, title "
f"FROM tasks WHERE id IN ({placeholders})",
tuple(task_ids),
).fetchall():
flags = []
if status == "running":
flags.append("status=running")
if lock:
flags.append(f"execution_lock=1 (since {locked_at or '?'})")
if flags:
findings.append(
StuckRow("task", tid, status, f"'{title}' " + ", ".join(flags))
)
combined_sessions: set[str] = set(session_ids)
if task_ids:
placeholders = ",".join("?" * len(task_ids))
for (sid,) in conn.execute(
f"SELECT DISTINCT session_id FROM runtime_sessions "
f"WHERE task_id IN ({placeholders})",
tuple(task_ids),
).fetchall():
if sid:
combined_sessions.add(sid)
if combined_sessions:
placeholders = ",".join("?" * len(combined_sessions))
for (rsid, sid, tid, status, updated_at) in conn.execute(
f"SELECT runtime_session_id, session_id, task_id, status, updated_at "
f"FROM runtime_sessions WHERE session_id IN ({placeholders}) AND status = 'running'",
tuple(combined_sessions),
).fetchall():
findings.append(
StuckRow(
"runtime_session",
rsid,
status,
f"session={sid} task={tid} updated={updated_at}",
)
)
return findings
def _apply_reset(
conn: sqlite3.Connection,
task_ids: set[str],
session_ids: set[str],
) -> dict[str, int]:
now = _utc_now_iso()
counts = {"tasks_reset": 0, "locks_cleared": 0, "runtime_sessions_ended": 0}
if task_ids:
placeholders = ",".join("?" * len(task_ids))
cur = conn.execute(
f"UPDATE tasks SET status = 'pending' "
f"WHERE id IN ({placeholders}) AND status = 'running'",
tuple(task_ids),
)
counts["tasks_reset"] = cur.rowcount or 0
cur = conn.execute(
f"UPDATE tasks SET execution_lock = 0, execution_locked_at = NULL "
f"WHERE id IN ({placeholders}) AND execution_lock = 1",
tuple(task_ids),
)
counts["locks_cleared"] = cur.rowcount or 0
combined_sessions: set[str] = set(session_ids)
if task_ids:
placeholders = ",".join("?" * len(task_ids))
for (sid,) in conn.execute(
f"SELECT DISTINCT session_id FROM runtime_sessions "
f"WHERE task_id IN ({placeholders})",
tuple(task_ids),
).fetchall():
if sid:
combined_sessions.add(sid)
if combined_sessions:
placeholders = ",".join("?" * len(combined_sessions))
cur = conn.execute(
f"UPDATE runtime_sessions SET status = 'ended', updated_at = ? "
f"WHERE session_id IN ({placeholders}) AND status = 'running'",
(now, *combined_sessions),
)
counts["runtime_sessions_ended"] = cur.rowcount or 0
conn.commit()
return counts
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--project", required=True, help="project_id (subdir under .opc/projects)")
parser.add_argument("--session", dest="session", help="session_id OR session title (e.g. 'app06')")
parser.add_argument("--task-id", dest="task_id", help="specific task id to reset")
parser.add_argument("--all", action="store_true", help="scan project-wide for stuck rows")
parser.add_argument("--apply", action="store_true", help="actually write changes (default is dry-run)")
args = parser.parse_args()
if not (args.session or args.task_id or args.all):
parser.error("provide one of --session, --task-id, or --all")
db_path = _project_db_path(args.project)
if not db_path.exists():
print(f"ERROR: project database not found: {db_path}", file=sys.stderr)
return 3
print(f"Project DB : {db_path}")
print(f"Mode : {'APPLY (writes)' if args.apply else 'DRY-RUN (read-only)'}")
print()
conn = sqlite3.connect(str(db_path))
try:
task_ids, session_ids = _resolve_targets(
conn,
task_id=args.task_id,
session_id_or_title=args.session,
include_all=args.all,
)
if not task_ids and not session_ids:
print("No matching tasks or sessions found for the given filters.")
return 0
print(f"Matched tasks : {len(task_ids)}")
print(f"Matched sessions: {len(session_ids)}")
findings = _scan_stuck(conn, task_ids, session_ids)
if not findings:
print("Nothing looks stuck for the matched targets.")
return 0
print(f"\nStuck rows ({len(findings)}):")
for row in findings:
print(row.render())
if not args.apply:
print("\nDry-run: re-run with --apply to reset these rows.")
return 0
counts = _apply_reset(conn, task_ids, session_ids)
print("\nApplied:")
print(f" tasks reset (running -> pending) : {counts['tasks_reset']}")
print(f" execution locks cleared : {counts['locks_cleared']}")
print(f" runtime_sessions ended : {counts['runtime_sessions_ended']}")
print("\nNext steps (required for in-memory recovery):")
print(" 1. Restart the OPC office_ui server (it holds asyncio locks in memory).")
print(" 2. In the browser, hard-reload the tab (Ctrl+Shift+R) to drop stale client state.")
print(" Optional: clear localStorage for the OpenOPC origin if the board still looks stale.")
return 0
finally:
conn.close()
if __name__ == "__main__":
sys.exit(main())