Add RX task watcher and approval workflow
This commit is contained in:
590
tools/rx_agent_watcher.py
Normal file
590
tools/rx_agent_watcher.py
Normal file
@@ -0,0 +1,590 @@
|
||||
"""Локальный Git-watcher заданий RX без LLM и доступа к SDR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
|
||||
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
|
||||
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
|
||||
REQUIRED_FIELDS = (
|
||||
"TASK_ID",
|
||||
"TASK_TITLE",
|
||||
"SOURCE_BRANCH",
|
||||
"EXPECTED_HEAD",
|
||||
"REQUIRES_APPROVAL",
|
||||
"HARDWARE_ACCESS",
|
||||
"RF_RX",
|
||||
"RF_TX",
|
||||
"ALLOW_COMMIT",
|
||||
"ALLOW_PUSH",
|
||||
)
|
||||
OPTIONAL_FIELDS = ("RISK_LEVEL",)
|
||||
HEADER_FIELDS = REQUIRED_FIELDS + OPTIONAL_FIELDS
|
||||
TASK_ID_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,63}$")
|
||||
BRANCH_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
||||
REMOTE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
HEAD_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinition:
|
||||
"""Проверенный машинно читаемый заголовок задания."""
|
||||
|
||||
task_id: str
|
||||
task_title: str
|
||||
source_branch: str
|
||||
expected_head: str
|
||||
requires_approval: bool
|
||||
hardware_access: bool
|
||||
rf_rx: bool
|
||||
rf_tx: bool
|
||||
allow_commit: bool
|
||||
allow_push: str
|
||||
risk_level: str
|
||||
|
||||
@property
|
||||
def safety_level(self) -> str:
|
||||
if self.rf_tx or self.risk_level == "RED":
|
||||
return "RED"
|
||||
if self.hardware_access or self.rf_rx or self.risk_level == "YELLOW":
|
||||
return "YELLOW"
|
||||
return "GREEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatcherState:
|
||||
"""Локальное состояние машины, которое не должно попадать в Git."""
|
||||
|
||||
last_seen_task_id: str | None = None
|
||||
last_completed_task_id: str | None = None
|
||||
deferred_task_id: str | None = None
|
||||
running: bool = False
|
||||
pid: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedResult:
|
||||
task_id: str
|
||||
task_status: str
|
||||
source_head: str
|
||||
started_at: str
|
||||
finished_at: str
|
||||
summary: str
|
||||
checks: list[str]
|
||||
hardware_actions: list[str]
|
||||
rf_tx: str
|
||||
files_created: list[str]
|
||||
errors: list[str]
|
||||
next_action: str
|
||||
|
||||
|
||||
class ApprovalDecision(Enum):
|
||||
APPROVE = "APPROVE"
|
||||
DEFER = "DEFER"
|
||||
STOP = "STOP"
|
||||
BLOCKED = "BLOCKED"
|
||||
|
||||
|
||||
class TaskExecutor(Protocol):
|
||||
"""Интерфейс исполнителя, не зависящий от конкретного LLM-клиента."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
task: TaskDefinition,
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
"""Подготовить локальный outbox без запуска агента."""
|
||||
...
|
||||
|
||||
|
||||
def _yes_no(value: str, field: str) -> bool:
|
||||
normalized = value.upper()
|
||||
if normalized not in {"YES", "NO"}:
|
||||
raise ValueError(f"{field} must be YES or NO")
|
||||
return normalized == "YES"
|
||||
|
||||
|
||||
def parse_task(text: str) -> TaskDefinition:
|
||||
"""Разобрать простой key-value заголовок и отклонить неоднозначность."""
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line == "---":
|
||||
continue
|
||||
if line.startswith("#") or line.startswith("```"):
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
if "=" not in line:
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
if key not in HEADER_FIELDS:
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
if key in values:
|
||||
raise ValueError(f"duplicate task header field: {key}")
|
||||
if not value:
|
||||
raise ValueError(f"empty task header field: {key}")
|
||||
values[key] = value
|
||||
|
||||
missing = [field for field in REQUIRED_FIELDS if field not in values]
|
||||
if missing:
|
||||
raise ValueError(f"missing task header fields: {', '.join(missing)}")
|
||||
task_id = values["TASK_ID"]
|
||||
branch = values["SOURCE_BRANCH"]
|
||||
expected_head = values["EXPECTED_HEAD"].lower()
|
||||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||||
raise ValueError("invalid TASK_ID")
|
||||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch or branch.endswith("/"):
|
||||
raise ValueError("invalid SOURCE_BRANCH")
|
||||
if not HEAD_PATTERN.fullmatch(expected_head):
|
||||
raise ValueError("EXPECTED_HEAD must be a full lowercase Git hash")
|
||||
allow_push = values["ALLOW_PUSH"].upper()
|
||||
if allow_push not in {"NO", "RX_RESULT_ONLY"}:
|
||||
raise ValueError("ALLOW_PUSH must be NO or RX_RESULT_ONLY")
|
||||
risk_level = values.get("RISK_LEVEL", "").upper()
|
||||
if risk_level and risk_level not in {"GREEN", "YELLOW", "RED"}:
|
||||
raise ValueError("RISK_LEVEL must be GREEN, YELLOW or RED")
|
||||
if not risk_level:
|
||||
risk_level = (
|
||||
"RED" if _yes_no(values["RF_TX"], "RF_TX") else
|
||||
"YELLOW" if (
|
||||
_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS")
|
||||
or _yes_no(values["RF_RX"], "RF_RX")
|
||||
) else "GREEN"
|
||||
)
|
||||
task = TaskDefinition(
|
||||
task_id=task_id,
|
||||
task_title=values["TASK_TITLE"],
|
||||
source_branch=branch,
|
||||
expected_head=expected_head,
|
||||
requires_approval=_yes_no(values["REQUIRES_APPROVAL"], "REQUIRES_APPROVAL"),
|
||||
hardware_access=_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS"),
|
||||
rf_rx=_yes_no(values["RF_RX"], "RF_RX"),
|
||||
rf_tx=_yes_no(values["RF_TX"], "RF_TX"),
|
||||
allow_commit=_yes_no(values["ALLOW_COMMIT"], "ALLOW_COMMIT"),
|
||||
allow_push=allow_push,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
if task.rf_tx and not task.hardware_access:
|
||||
raise ValueError("RF_TX=YES requires HARDWARE_ACCESS=YES")
|
||||
if task.rf_tx and task.risk_level != "RED":
|
||||
raise ValueError("RF_TX=YES requires RISK_LEVEL=RED")
|
||||
if (task.hardware_access or task.rf_rx) and task.risk_level == "GREEN":
|
||||
raise ValueError("hardware access cannot use RISK_LEVEL=GREEN")
|
||||
return task
|
||||
|
||||
|
||||
def load_state(path: Path) -> WatcherState:
|
||||
"""Прочитать локальное состояние; отсутствие файла означает первый запуск."""
|
||||
if not path.exists():
|
||||
return WatcherState()
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
allowed = set(WatcherState.__dataclass_fields__)
|
||||
if not isinstance(raw, dict) or any(key not in allowed for key in raw):
|
||||
raise ValueError("invalid watcher state")
|
||||
return WatcherState(**raw)
|
||||
|
||||
|
||||
def save_state(path: Path, state: WatcherState) -> None:
|
||||
"""Атомарно сохранить состояние рядом с целевым файлом."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def is_new_task(task: TaskDefinition, state: WatcherState) -> bool:
|
||||
"""Подавить задание, которое уже показывалось или было завершено."""
|
||||
return task.task_id not in {state.last_seen_task_id, state.last_completed_task_id}
|
||||
|
||||
|
||||
def task_summary(task: TaskDefinition) -> str:
|
||||
return (
|
||||
"NEW TASK DETECTED\n\n"
|
||||
f"TASK ID = {task.task_id}\n"
|
||||
f"TITLE = {task.task_title}\n"
|
||||
f"SAFETY LEVEL = {task.safety_level}\n"
|
||||
f"HARDWARE ACCESS = {'YES' if task.hardware_access else 'NO'}\n"
|
||||
f"RF RX = {'YES' if task.rf_rx else 'NO'}\n"
|
||||
f"RF TX = {'YES' if task.rf_tx else 'NO'}"
|
||||
)
|
||||
|
||||
|
||||
def request_approval(
|
||||
task: TaskDefinition,
|
||||
input_function: Callable[[str], str] = input,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> ApprovalDecision:
|
||||
"""Запросить A/D/S; для RED нужна вторая точная фраза."""
|
||||
output_function(task_summary(task))
|
||||
choice = input_function("[A] Approve [D] Defer [S] Stop watcher: ").strip().upper()
|
||||
if choice == "D":
|
||||
return ApprovalDecision.DEFER
|
||||
if choice == "S":
|
||||
return ApprovalDecision.STOP
|
||||
if choice != "A":
|
||||
return ApprovalDecision.BLOCKED
|
||||
if task.safety_level == "RED":
|
||||
required_phrase = "AUTHORIZE RF TX" if task.rf_tx else f"AUTHORIZE {task.task_id}"
|
||||
phrase = input_function(f"Type {required_phrase} to continue: ").strip()
|
||||
if phrase != required_phrase:
|
||||
return ApprovalDecision.BLOCKED
|
||||
return ApprovalDecision.APPROVE
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def write_result(output_directory: Path, result: PreparedResult) -> tuple[Path, Path]:
|
||||
"""Записать согласованные Markdown и JSON без публикации."""
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
markdown_path = output_directory / "RX_RESULT.md"
|
||||
json_path = output_directory / "RX_RESULT.json"
|
||||
markdown = (
|
||||
f"TASK_ID = {result.task_id}\n"
|
||||
f"TASK_STATUS = {result.task_status}\n"
|
||||
f"SOURCE_HEAD = {result.source_head}\n"
|
||||
f"STARTED_AT = {result.started_at}\n"
|
||||
f"FINISHED_AT = {result.finished_at}\n\n"
|
||||
f"SUMMARY =\n{result.summary}\n\n"
|
||||
f"CHECKS =\n{json.dumps(result.checks)}\n\n"
|
||||
f"HARDWARE_ACTIONS =\n{json.dumps(result.hardware_actions)}\n\n"
|
||||
f"RF_TX =\n{result.rf_tx}\n\n"
|
||||
f"FILES_CREATED =\n{json.dumps(result.files_created)}\n\n"
|
||||
f"ERRORS =\n{json.dumps(result.errors)}\n\n"
|
||||
f"NEXT_ACTION =\n{result.next_action}\n"
|
||||
)
|
||||
markdown_path.write_text(markdown, encoding="utf-8")
|
||||
json_path.write_text(json.dumps(asdict(result), indent=2) + "\n", encoding="utf-8")
|
||||
return markdown_path, json_path
|
||||
|
||||
|
||||
class ManualExecutor:
|
||||
"""Подготовить локальный prompt и каркас результата без запуска агента."""
|
||||
|
||||
def __init__(self, outbox: Path = DEFAULT_OUTBOX) -> None:
|
||||
self.outbox = outbox
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
task: TaskDefinition,
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
directory = self.outbox / task.task_id
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
prompt_path = directory / "TASK.md"
|
||||
prompt_path.write_text(task_text, encoding="utf-8")
|
||||
result_markdown_path = directory / "RX_RESULT.md"
|
||||
result_json_path = directory / "RX_RESULT.json"
|
||||
timestamp = utc_now()
|
||||
status = "DRY_RUN_PREPARED" if dry_run else "AWAITING_MANUAL_EXECUTION"
|
||||
result = PreparedResult(
|
||||
task_id=task.task_id,
|
||||
task_status=status,
|
||||
source_head=source_head,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
summary="Task prepared for manual transfer; no agent was invoked.",
|
||||
checks=["TASK_HEADER_VALID", "APPROVAL_GATE_PASS"],
|
||||
hardware_actions=[],
|
||||
rf_tx="NO",
|
||||
files_created=[
|
||||
str(prompt_path),
|
||||
str(result_markdown_path),
|
||||
str(result_json_path),
|
||||
],
|
||||
errors=[],
|
||||
next_action="Open TASK.md in the chosen executor after explicit user approval.",
|
||||
)
|
||||
markdown_path, json_path = write_result(directory, result)
|
||||
return prompt_path, markdown_path, json_path
|
||||
|
||||
|
||||
class GitTaskSource:
|
||||
"""Прочитать задание непосредственно из remote-tracking reference."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = "video-development",
|
||||
task_path: str = DEFAULT_TASK_PATH,
|
||||
) -> None:
|
||||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch:
|
||||
raise ValueError("invalid branch")
|
||||
if not REMOTE_PATTERN.fullmatch(remote):
|
||||
raise ValueError("invalid remote")
|
||||
self.repository = repository
|
||||
self.remote = remote
|
||||
self.branch = branch
|
||||
self.task_path = task_path
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=self.repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=check,
|
||||
)
|
||||
|
||||
def fetch(self) -> None:
|
||||
self._run(["fetch", "--quiet", self.remote, self.branch])
|
||||
|
||||
def read(self) -> tuple[str, str]:
|
||||
remote_ref = f"{self.remote}/{self.branch}"
|
||||
head = self._run(["rev-parse", remote_ref]).stdout.strip()
|
||||
text = self._run(["show", f"{remote_ref}:{self.task_path}"]).stdout
|
||||
task = parse_task(text)
|
||||
if task.source_branch != self.branch:
|
||||
raise ValueError("task SOURCE_BRANCH disagrees with watched branch")
|
||||
ancestor = self._run(
|
||||
["merge-base", "--is-ancestor", task.expected_head, head],
|
||||
check=False,
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise ValueError("EXPECTED_HEAD is not an ancestor of remote task HEAD")
|
||||
return text, head
|
||||
|
||||
|
||||
def handle_task(
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
state: WatcherState,
|
||||
executor: TaskExecutor,
|
||||
*,
|
||||
dry_run: bool,
|
||||
input_function: Callable[[str], str] = input,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> tuple[str, WatcherState]:
|
||||
"""Обработать одно задание без изменений Git и запуска агента."""
|
||||
task = parse_task(task_text)
|
||||
if not is_new_task(task, state):
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
decision = request_approval(task, input_function, output_function)
|
||||
state.last_seen_task_id = task.task_id
|
||||
if decision == ApprovalDecision.DEFER:
|
||||
state.deferred_task_id = task.task_id
|
||||
return "DEFERRED", state
|
||||
if decision == ApprovalDecision.STOP:
|
||||
return "STOP", state
|
||||
if decision != ApprovalDecision.APPROVE:
|
||||
return "APPROVAL_BLOCKED", state
|
||||
state.deferred_task_id = None
|
||||
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
|
||||
output_function(f"TASK PREPARED = {paths[0]}")
|
||||
return "PREPARED", state
|
||||
|
||||
|
||||
def stop_path_for(state_path: Path) -> Path:
|
||||
return state_path.with_suffix(state_path.suffix + ".stop")
|
||||
|
||||
|
||||
def process_exists(pid: int | None) -> bool:
|
||||
if pid is None or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||
"""Запросить остановку watcher и дождаться снятия running state."""
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stop_path.write_text("STOP\n", encoding="ascii")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
state = load_state(state_path)
|
||||
if not state.running or not process_exists(state.pid):
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def interruptible_input(prompt: str, stop_path: Path) -> str:
|
||||
"""Читать консоль Windows, позволяя stop script прервать ожидание."""
|
||||
if os.name != "nt":
|
||||
return input(prompt)
|
||||
import msvcrt
|
||||
|
||||
print(prompt, end="", flush=True)
|
||||
characters: list[str] = []
|
||||
while True:
|
||||
if stop_path.exists():
|
||||
print()
|
||||
return "S"
|
||||
if not msvcrt.kbhit():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
character = msvcrt.getwch()
|
||||
if character in {"\r", "\n"}:
|
||||
print()
|
||||
return "".join(characters)
|
||||
if character == "\b":
|
||||
if characters:
|
||||
characters.pop()
|
||||
print("\b \b", end="", flush=True)
|
||||
continue
|
||||
characters.append(character)
|
||||
print(character, end="", flush=True)
|
||||
|
||||
|
||||
def mark_completed(state_path: Path, task_id: str) -> None:
|
||||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||||
raise ValueError("invalid TASK_ID")
|
||||
state = load_state(state_path)
|
||||
if state.last_seen_task_id != task_id:
|
||||
raise ValueError("only the last seen task may be marked completed")
|
||||
state.last_completed_task_id = task_id
|
||||
state.deferred_task_id = None
|
||||
save_state(state_path, state)
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||||
return 0 if stopped else 1
|
||||
if arguments.mark_completed is not None:
|
||||
mark_completed(state_path, arguments.mark_completed)
|
||||
print(f"COMPLETED TASK = {arguments.mark_completed}")
|
||||
return 0
|
||||
if arguments.dry_run and arguments.task_file is None:
|
||||
raise ValueError("--dry-run requires --task-file and never fetches Git")
|
||||
|
||||
state = load_state(state_path)
|
||||
if state.running and process_exists(state.pid):
|
||||
raise RuntimeError(f"watcher already running with PID {state.pid}")
|
||||
state.running = True
|
||||
state.pid = os.getpid()
|
||||
save_state(state_path, state)
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
executor = ManualExecutor(arguments.outbox.resolve())
|
||||
source = GitTaskSource(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
branch=arguments.branch,
|
||||
)
|
||||
if arguments.approval is not None:
|
||||
replies = [
|
||||
"A" if arguments.approval == "approve" else
|
||||
"D" if arguments.approval == "defer" else "S"
|
||||
]
|
||||
iterator = iter(replies)
|
||||
|
||||
def automated_input(_prompt: str) -> str:
|
||||
return next(iterator, "")
|
||||
|
||||
input_function = automated_input
|
||||
else:
|
||||
def console_input(prompt: str) -> str:
|
||||
return interruptible_input(prompt, stop_path)
|
||||
|
||||
input_function = console_input
|
||||
|
||||
try:
|
||||
while not stop_path.exists():
|
||||
try:
|
||||
if arguments.task_file is not None:
|
||||
task_text = arguments.task_file.read_text(encoding="utf-8")
|
||||
source_head = parse_task(task_text).expected_head
|
||||
else:
|
||||
source.fetch()
|
||||
task_text, source_head = source.read()
|
||||
status, state = handle_task(
|
||||
task_text,
|
||||
source_head,
|
||||
state,
|
||||
executor,
|
||||
dry_run=arguments.dry_run,
|
||||
input_function=input_function,
|
||||
)
|
||||
save_state(state_path, state)
|
||||
if status != "DUPLICATE_SUPPRESSED":
|
||||
print(f"WATCHER STATUS = {status}")
|
||||
if status == "STOP":
|
||||
break
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as error:
|
||||
print(f"WATCHER ERROR = {error}", file=sys.stderr)
|
||||
if arguments.once or arguments.dry_run:
|
||||
return 1
|
||||
if arguments.once or arguments.dry_run or arguments.task_file is not None:
|
||||
break
|
||||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for _ in range(max(1, int(arguments.poll_interval * 10))):
|
||||
if stop_path.exists():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--branch", default="video-development")
|
||||
parser.add_argument("--poll-interval", type=float, default=30.0)
|
||||
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
||||
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--task-file", type=Path)
|
||||
parser.add_argument("--approval", choices=("approve", "defer", "stop"))
|
||||
parser.add_argument("--request-stop", action="store_true")
|
||||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||||
parser.add_argument("--mark-completed")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.poll_interval <= 0 or arguments.stop_timeout <= 0:
|
||||
raise SystemExit("intervals must be positive")
|
||||
raise SystemExit(run_watcher(arguments))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
tools/start_rx_watcher.cmd
Normal file
9
tools/start_rx_watcher.cmd
Normal file
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\rx_agent_watcher.py
|
||||
set "RX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
echo RX watcher exited with code %RX_WATCHER_EXIT%.
|
||||
pause
|
||||
exit /b %RX_WATCHER_EXIT%
|
||||
8
tools/stop_rx_watcher.cmd
Normal file
8
tools/stop_rx_watcher.cmd
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\rx_agent_watcher.py --request-stop --stop-timeout 35
|
||||
set "RX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
pause
|
||||
exit /b %RX_WATCHER_EXIT%
|
||||
Reference in New Issue
Block a user