296 lines
10 KiB
Python
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())
|