83 lines
3.0 KiB
Python
83 lines
3.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded Kanban access and workspace resolution for Hermes CLI workers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from cli_lane_config import (
|
|
BOARD_CORRUPTION_ERRORS,
|
|
EXTERNAL_PREFIX,
|
|
KANBAN_STORAGE_ATTEMPTS,
|
|
WORKTREE_LOCK,
|
|
)
|
|
|
|
|
|
def _task_value(task: Any, name: str, default: Any = None) -> Any:
|
|
return getattr(task, name, default)
|
|
|
|
def _task_context(kanban_db: Any, conn: Any, task_id: str) -> str:
|
|
value = kanban_db.build_worker_context(conn, task_id)
|
|
if isinstance(value, str):
|
|
return value
|
|
return json.dumps(value, indent=2, default=str)
|
|
|
|
def _resolve_workspace(kanban_db: Any, conn: Any, task: Any, board: str) -> Path:
|
|
# External coding lanes always receive their own linked worktree. A task
|
|
# that does not resolve to a Git repository is blocked rather than sharing
|
|
# a mutable checkout with another unattended worker.
|
|
# Git worktree creation updates common-repository metadata. Serialize that
|
|
# short materialization step while allowing the provider workers themselves
|
|
# to run concurrently in independent worktrees.
|
|
with WORKTREE_LOCK:
|
|
value, branch_name = kanban_db._resolve_worktree_workspace(task, board=board)
|
|
kanban_db.set_branch_name(conn, str(_task_value(task, "id")), branch_name)
|
|
return Path(value).resolve()
|
|
|
|
def _record_board_access_error(board: str, error: Exception) -> None:
|
|
"""Report one board access failure once while allowing other lanes to run."""
|
|
failure_kind = "storage" if isinstance(error, (OSError, sqlite3.Error)) else "access"
|
|
detail = f"{failure_kind} {type(error).__name__}: {error}"
|
|
if BOARD_CORRUPTION_ERRORS.get(board) == detail:
|
|
return
|
|
print(
|
|
f"temporarily skipping Kanban board {board!r}: {detail}",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
BOARD_CORRUPTION_ERRORS[board] = detail
|
|
|
|
def _board_call(
|
|
kanban_db: Any,
|
|
board: str,
|
|
operation: Callable[[Any], Any],
|
|
) -> Any:
|
|
"""Run one bounded Kanban operation on a fresh, promptly closed connection."""
|
|
last_storage_error: Exception | None = None
|
|
for attempt in range(KANBAN_STORAGE_ATTEMPTS):
|
|
conn = None
|
|
try:
|
|
with kanban_db.scoped_current_board(board):
|
|
conn = kanban_db.connect(board=board)
|
|
result = operation(conn)
|
|
BOARD_CORRUPTION_ERRORS.pop(board, None)
|
|
return result
|
|
except (OSError, sqlite3.Error) as error:
|
|
last_storage_error = error
|
|
_record_board_access_error(board, error)
|
|
if attempt + 1 < KANBAN_STORAGE_ATTEMPTS:
|
|
time.sleep(0.2 * (attempt + 1))
|
|
finally:
|
|
if conn is not None:
|
|
conn.close()
|
|
assert last_storage_error is not None
|
|
raise last_storage_error
|
|
|
|
def _external(task: Any) -> bool:
|
|
return str(_task_value(task, "assignee", "") or "").startswith(EXTERNAL_PREFIX)
|