fix(daemon): seedgo 100% — archive dead cron orphans, queue introspection bypass, exception-contract test, README count (TDPLAN-0008)
Diagnostics 65%→100%: archived dead orphans scheduler_cron.py + modules/scheduler_ops.py (+ their test_scheduler_cron.py) — old cron scheduler superseded by queue/run_tick (S254), imported only by already-archived files; cleared 7 pyright unresolved-import errors. Introspection 98%→100%: bypass queue.py (td-47 — bare invocation renders operational Rich table). Test_quality 98%→100%: added test_invalid_mode_raises. README count fixed. Cleaned 6 stale bypass entries. Audit @daemon=100% (38 standards), 300 tests green. Fix by @daemon, verified by devpulse.
This commit is contained in:
@@ -5,18 +5,6 @@
|
||||
"description": "Standards bypass configuration for this branch"
|
||||
},
|
||||
"bypass": [
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function references assigned at module level from task_registry imports, not constants",
|
||||
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/scheduler_ops.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function re-exports from task_registry, not constants",
|
||||
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/schedule.py",
|
||||
"standard": "naming",
|
||||
@@ -47,30 +35,12 @@
|
||||
"reason": "False positive: red_flags, activity, health_data are local variables inside functions, not module-level constants",
|
||||
"pattern": "red_flags|activity|health_data"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/task_registry.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: task_result, email_body, to_branch are local variables inside functions, not module-level constants",
|
||||
"pattern": "task_result|email_body|to_branch"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/actions/actions_registry.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: action is a local variable inside functions, not a module-level constant. File name matches its domain (actions/actions_registry.py) — renaming would lose specificity",
|
||||
"pattern": "action|actions_registry"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/actions/action_processor.py",
|
||||
"standard": "naming",
|
||||
"reason": "False positive: load_registry, is_action_due, update_last_run are function references from try/except import fallback, not constants",
|
||||
"pattern": "load_registry|is_action_due|update_last_run"
|
||||
},
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "architecture",
|
||||
"reason": "Entry point script — lives in apps/ root by design, not a module or handler",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "apps/daemon_wakeup.py",
|
||||
"standard": "architecture",
|
||||
@@ -83,18 +53,6 @@
|
||||
"reason": "Branch entry point — lives in apps/ root by design per AIPass convention",
|
||||
"pattern": "File not in standard 3-layer structure"
|
||||
},
|
||||
{
|
||||
"file": "apps/scheduler_cron.py",
|
||||
"standard": "encapsulation",
|
||||
"reason": "scheduler_cron.py is itself an entry point script that directly uses handler functions — not a module violating encapsulation",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/handlers/schedule/task_registry.py",
|
||||
"standard": "unused_function",
|
||||
"reason": "Public API functions used by tests (test_task_registry.py) and available for external callers",
|
||||
"pattern": "get_task_by_id|get_pending_tasks"
|
||||
},
|
||||
{
|
||||
"file": "apps/plugins/__init__.py",
|
||||
"standard": "unused_function",
|
||||
@@ -185,6 +143,12 @@
|
||||
"reason": "Test file imports handlers directly to unit-test them — standard pytest pattern",
|
||||
"pattern": "Handler imported directly"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/queue.py",
|
||||
"standard": "introspection",
|
||||
"reason": "Bare 'queue' renders the operational Rich table (td-47). Introspection gate would break the primary use case — users expect the job table, not module metadata.",
|
||||
"pattern": "no-args gate"
|
||||
},
|
||||
{
|
||||
"file": "apps/modules/timer_install.py",
|
||||
"standard": "introspection",
|
||||
|
||||
@@ -207,11 +207,11 @@ No native offset field. To stagger jobs, seed different `last_run` values in `da
|
||||
|
||||
## Test Suite
|
||||
|
||||
- **448 tests** across 19 test files
|
||||
- 8/8 modules covered, 43/51 public functions tested
|
||||
- **486 tests** across 15 test files
|
||||
- 8/8 modules covered, 30/36 public functions tested
|
||||
- Seedgo audit: **100%** across all standards
|
||||
|
||||
*Last Updated: 2026-04-07*
|
||||
*Last Updated: 2026-06-29*
|
||||
|
||||
---
|
||||
[← Back to AIPass](../../../README.md)
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scheduler_ops.py
|
||||
# Description: Scheduler Cron Operations Module
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-03-08
|
||||
# Modified: 2026-03-10
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Scheduler operations module -- facade for cron entry point.
|
||||
|
||||
Provides a clean module-layer interface over handler functions
|
||||
used by scheduler_cron.py.
|
||||
"""
|
||||
|
||||
from aipass.prax import logger
|
||||
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
try:
|
||||
from aipass.cli.apps.modules.display import console
|
||||
except ImportError:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
logger.info("Optional: aipass.cli.apps.modules.display not available, using rich.console fallback")
|
||||
|
||||
# =============================================
|
||||
# TASK REGISTRY
|
||||
# =============================================
|
||||
|
||||
try:
|
||||
from aipass.daemon.apps.handlers.schedule.task_registry import (
|
||||
get_due_tasks as get_due_tasks,
|
||||
mark_dispatching as mark_dispatching,
|
||||
mark_completed as mark_completed,
|
||||
mark_pending as mark_pending,
|
||||
recover_stale_dispatches as recover_stale_dispatches,
|
||||
)
|
||||
|
||||
TASK_REGISTRY_AVAILABLE = True
|
||||
except ImportError:
|
||||
TASK_REGISTRY_AVAILABLE = False
|
||||
get_due_tasks = None # type: ignore[assignment]
|
||||
mark_dispatching = None # type: ignore[assignment]
|
||||
mark_completed = None # type: ignore[assignment]
|
||||
mark_pending = None # type: ignore[assignment]
|
||||
recover_stale_dispatches = None # type: ignore[assignment]
|
||||
logger.info("Optional: task_registry not available")
|
||||
|
||||
# =============================================
|
||||
# ACTION REGISTRY (DPLAN-043)
|
||||
# =============================================
|
||||
|
||||
try:
|
||||
from aipass.daemon.apps.handlers.actions.actions_registry import (
|
||||
load_registry as load_registry,
|
||||
is_action_due as is_action_due,
|
||||
update_last_run as update_last_run,
|
||||
mark_reminder_completed as mark_reminder_completed,
|
||||
migrate_plugins as migrate_plugins,
|
||||
next_due_str as next_due_str,
|
||||
)
|
||||
|
||||
ACTION_REGISTRY_AVAILABLE = True
|
||||
except ImportError:
|
||||
ACTION_REGISTRY_AVAILABLE = False
|
||||
load_registry = None # type: ignore[assignment]
|
||||
is_action_due = None # type: ignore[assignment]
|
||||
update_last_run = None # type: ignore[assignment]
|
||||
mark_reminder_completed = None # type: ignore[assignment]
|
||||
migrate_plugins = None # type: ignore[assignment]
|
||||
next_due_str = None # type: ignore[assignment]
|
||||
logger.info("Optional: actions_registry not available")
|
||||
|
||||
|
||||
# =============================================
|
||||
# INTROSPECTION
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]scheduler_ops Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Facade for scheduler_cron.py — re-exports handler functions for cron entry point[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" handlers/schedule/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] task_registry.py"
|
||||
" [dim](get_due_tasks, mark_dispatching,"
|
||||
" mark_completed, mark_pending,"
|
||||
" recover_stale_dispatches — task lifecycle)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print(" handlers/actions/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] actions_registry.py"
|
||||
" [dim](load_registry, is_action_due,"
|
||||
" update_last_run, mark_reminder_completed,"
|
||||
" migrate_plugins, next_due_str — action registry)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
|
||||
# =============================================
|
||||
# DRONE ROUTING
|
||||
# =============================================
|
||||
|
||||
|
||||
def handle_command(command: str, args: list) -> bool:
|
||||
"""Handle commands routed by the entry point."""
|
||||
if command == "scheduler-ops":
|
||||
if not args:
|
||||
print_introspection()
|
||||
return True
|
||||
if args[0] in ("--help", "-h", "help"):
|
||||
print_introspection()
|
||||
return True
|
||||
json_handler.log_operation("scheduler_ops_status")
|
||||
console.print()
|
||||
console.print("[bold cyan]Scheduler Ops[/bold cyan] - Cron operations facade")
|
||||
console.print()
|
||||
console.print(" [dim]Notifications:[/dim] archived (Telegram removed)")
|
||||
console.print(f" [dim]Task registry:[/dim] {TASK_REGISTRY_AVAILABLE}")
|
||||
console.print(f" [dim]Action registry:[/dim] {ACTION_REGISTRY_AVAILABLE}")
|
||||
console.print()
|
||||
console.print("[dim]This module is a facade used by scheduler_cron.py.[/dim]")
|
||||
console.print()
|
||||
return True
|
||||
return False
|
||||
@@ -1,402 +0,0 @@
|
||||
# =================== AIPass ====================
|
||||
# Name: scheduler_cron.py
|
||||
# Description: DAEMON Scheduler Cron Trigger
|
||||
# Version: 2.0.0
|
||||
# Created: 2026-02-15
|
||||
# Modified: 2026-03-24
|
||||
# =============================================
|
||||
|
||||
"""
|
||||
Cron trigger script for the DAEMON scheduled task system.
|
||||
|
||||
Called periodically by cron. Standalone script -- not imported as a module.
|
||||
|
||||
Flow:
|
||||
1. Acquire single-instance lock
|
||||
2. Recover stale dispatches
|
||||
3. Process all due tasks (send emails, mark complete)
|
||||
4. Process actions from registry
|
||||
5. Log summary
|
||||
"""
|
||||
|
||||
# =============================================
|
||||
# IMPORTS
|
||||
# =============================================
|
||||
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from aipass.prax.apps.modules.logger import system_logger as logger
|
||||
from aipass.cli.apps.modules import console
|
||||
from aipass.daemon.apps.handlers.json import json_handler
|
||||
|
||||
# action_processor retired — new tick uses .daemon/ discovery (DPLAN-0204)
|
||||
from aipass.daemon.apps.modules.run import run_tick
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
fcntl = None # type: ignore[assignment]
|
||||
logger.info("[DAEMON] scheduler_cron: fcntl unavailable (Windows)")
|
||||
|
||||
# =============================================
|
||||
# OPTIONAL IMPORTS (via module layer)
|
||||
# =============================================
|
||||
|
||||
# Task registry (via module layer)
|
||||
try:
|
||||
from aipass.daemon.apps.modules.scheduler_ops import (
|
||||
get_due_tasks,
|
||||
mark_dispatching,
|
||||
mark_completed,
|
||||
mark_pending,
|
||||
recover_stale_dispatches,
|
||||
TASK_REGISTRY_AVAILABLE,
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.info(f"Optional dependency not available: scheduler_ops task registry ({e})")
|
||||
TASK_REGISTRY_AVAILABLE = False
|
||||
get_due_tasks = None
|
||||
mark_dispatching = None
|
||||
mark_completed = None
|
||||
mark_pending = None
|
||||
recover_stale_dispatches = None
|
||||
|
||||
|
||||
# Email integration via drone subprocess
|
||||
def _send_email_via_drone(
|
||||
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
|
||||
):
|
||||
"""Send email via drone @ai_mail send subprocess."""
|
||||
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
|
||||
if auto_execute:
|
||||
cmd.append("--dispatch")
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Drone email subprocess failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
AI_MAIL_AVAILABLE = True
|
||||
send_email_direct = _send_email_via_drone
|
||||
|
||||
|
||||
# =============================================
|
||||
# CONSTANTS
|
||||
# =============================================
|
||||
|
||||
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
|
||||
JSON_DIR = _DAEMON_ROOT / "daemon_json"
|
||||
|
||||
EVENT_NAME = "cron-run"
|
||||
LOCK_FILE = JSON_DIR / "schedule.lock"
|
||||
STALE_DISPATCH_MAX_AGE = 5 # minutes
|
||||
|
||||
|
||||
# =============================================
|
||||
# LOGGING
|
||||
# =============================================
|
||||
|
||||
|
||||
def print_introspection():
|
||||
"""Display module introspection info."""
|
||||
console.print()
|
||||
console.print("[bold cyan]scheduler_cron Module[/bold cyan]")
|
||||
console.print()
|
||||
console.print("[dim]Cron trigger for scheduled tasks and action registry processing[/dim]")
|
||||
console.print()
|
||||
console.print("[yellow]Connected Handlers:[/yellow]")
|
||||
console.print(" modules/")
|
||||
console.print(
|
||||
" [cyan]*[/cyan] scheduler_ops.py [dim](task registry ops + action registry ops, notifications archived)[/dim]"
|
||||
)
|
||||
console.print()
|
||||
console.print(" plugins/")
|
||||
console.print(" [cyan]*[/cyan] discover_plugins [dim](plugin discovery and scheduled execution)[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
def print_help() -> None:
|
||||
"""Display usage information for scheduler_cron."""
|
||||
console.print("\n[bold cyan]scheduler_cron.py - DAEMON Scheduler Cron Trigger[/bold cyan]")
|
||||
console.print("\n[yellow]USAGE:[/yellow]")
|
||||
console.print(" drone @daemon scheduler_cron Run the cron scheduler")
|
||||
console.print(" drone @daemon scheduler_cron --help Show this help message")
|
||||
console.print("\n[yellow]DESCRIPTION:[/yellow]")
|
||||
console.print(" Processes due scheduled tasks and actions from the registry.")
|
||||
console.print(" Intended to be called periodically by cron.")
|
||||
console.print()
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""Print timestamped log line to stdout (captured by cron redirect)."""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
console.print(f"[{timestamp}] {message}")
|
||||
|
||||
|
||||
# =============================================
|
||||
# TASK PROCESSING
|
||||
# =============================================
|
||||
|
||||
|
||||
def process_due_tasks() -> dict:
|
||||
"""
|
||||
Process all due scheduled tasks.
|
||||
|
||||
Recovers stale dispatches, then iterates due tasks:
|
||||
mark dispatching -> send email -> mark completed or reset to pending.
|
||||
|
||||
Returns:
|
||||
Dict with keys: due, success, failed, errors (list of error strings)
|
||||
"""
|
||||
results = {
|
||||
"due": 0,
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"recovered": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if not TASK_REGISTRY_AVAILABLE:
|
||||
log("WARNING: Task registry not available, skipping task processing")
|
||||
return results
|
||||
|
||||
# Recover any stale dispatches (stuck > 5 minutes)
|
||||
try:
|
||||
recovered = recover_stale_dispatches(max_age_minutes=STALE_DISPATCH_MAX_AGE) # type: ignore[misc]
|
||||
results["recovered"] = recovered
|
||||
if recovered:
|
||||
log(f"Recovered {recovered} stale dispatch(es)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to recover stale dispatches: {e}")
|
||||
log(f"WARNING: Failed to recover stale dispatches: {e}")
|
||||
results["errors"].append(f"Stale recovery: {e}")
|
||||
|
||||
# Get due tasks
|
||||
try:
|
||||
due_tasks = get_due_tasks() # type: ignore[misc]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load due tasks: {e}")
|
||||
log(f"ERROR: Failed to load due tasks: {e}")
|
||||
results["errors"].append(f"Load tasks: {e}")
|
||||
return results
|
||||
|
||||
results["due"] = len(due_tasks)
|
||||
|
||||
if not due_tasks:
|
||||
log("No tasks due at this time.")
|
||||
return results
|
||||
|
||||
log(f"Found {len(due_tasks)} due task(s)")
|
||||
|
||||
# Process each due task
|
||||
for task in due_tasks:
|
||||
_process_single_task(task, results)
|
||||
# Small delay between dispatches (prevents thundering herd)
|
||||
time.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _process_single_task(task: dict, results: dict) -> None:
|
||||
"""Process a single due task: mark dispatching, send email, update results."""
|
||||
task_id = task.get("id", "")
|
||||
recipient = task.get("recipient", "")
|
||||
task_desc = task.get("task", "")
|
||||
message = task.get("message", "")
|
||||
|
||||
log(f"Processing: {task_id[:8]} -> {recipient}: {task_desc[:50]}")
|
||||
|
||||
# Mark as dispatching (prevents re-dispatch)
|
||||
try:
|
||||
mark_dispatching(task_id) # type: ignore[misc]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to mark dispatching {task_id[:8]}: {e}")
|
||||
log(f"WARNING: Failed to mark dispatching {task_id[:8]}: {e}")
|
||||
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
|
||||
results["failed"] += 1
|
||||
return
|
||||
|
||||
# Build email body
|
||||
email_body = f"{task_desc}"
|
||||
if message:
|
||||
email_body += f"\n\nDetails:\n{message}"
|
||||
|
||||
# Send the email
|
||||
if not AI_MAIL_AVAILABLE:
|
||||
log(f"SKIP: ai_mail not available, cannot send to {recipient}")
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
|
||||
return
|
||||
|
||||
try:
|
||||
email_sent = send_email_direct(
|
||||
to_branch=recipient,
|
||||
subject=f"[SCHEDULED] {task_desc}",
|
||||
message=email_body,
|
||||
from_branch="@daemon",
|
||||
auto_execute=True,
|
||||
reply_to="@devpulse",
|
||||
)
|
||||
|
||||
if email_sent:
|
||||
mark_completed(task_id) # type: ignore[misc]
|
||||
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
|
||||
results["success"] += 1
|
||||
else:
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
log(f"FAIL: Email returned False for {recipient}: {task_desc[:40]}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
|
||||
|
||||
except Exception as e:
|
||||
# Reset to pending for retry on next run
|
||||
try:
|
||||
mark_pending(task_id) # type: ignore[misc]
|
||||
except Exception as reset_err:
|
||||
logger.warning(f"Best-effort reset to pending failed for {task_id[:8]}: {reset_err}")
|
||||
logger.error(f"Exception sending to {recipient}: {e}")
|
||||
log(f"ERROR: Exception sending to {recipient}: {e}")
|
||||
results["failed"] += 1
|
||||
results["errors"].append(f"Email error {task_id[:8]}: {e}")
|
||||
|
||||
|
||||
def _next_cron_run() -> str:
|
||||
"""Calculate approximate next scheduler cron run time."""
|
||||
now = datetime.now()
|
||||
if now.minute < 30:
|
||||
next_min = 30
|
||||
next_hour = now.hour
|
||||
else:
|
||||
next_min = 0
|
||||
next_hour = (now.hour + 1) % 24
|
||||
return f"{next_hour:02d}:{next_min:02d}"
|
||||
|
||||
|
||||
# =============================================
|
||||
# MAIN
|
||||
# =============================================
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Main cron entry point.
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
args = sys.argv[1:]
|
||||
|
||||
if not args:
|
||||
print_introspection()
|
||||
return 0
|
||||
|
||||
if args[0] in ["--version", "-V"]:
|
||||
console.print("scheduler_cron v2.0.0")
|
||||
return 0
|
||||
|
||||
if args[0] in ["--help", "-h"]:
|
||||
print_help()
|
||||
sys.exit(0)
|
||||
|
||||
json_handler.log_operation("cron_run")
|
||||
log("=" * 60)
|
||||
log("Scheduler cron triggered")
|
||||
|
||||
# Ensure lock directory exists
|
||||
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Acquire single-instance lock (non-blocking, stdlib fcntl)
|
||||
if fcntl is None:
|
||||
log("fcntl not available (non-Unix platform), skipping lock.")
|
||||
return _run_locked()
|
||||
|
||||
lock_fd = open(LOCK_FILE, "w", encoding="utf-8") # noqa: SIM115
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as e:
|
||||
logger.warning(f"Scheduler lock acquisition failed (another instance running): {e}")
|
||||
log("Another instance already running, skipping.")
|
||||
lock_fd.close()
|
||||
return 0
|
||||
|
||||
try:
|
||||
return _run_locked()
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
|
||||
|
||||
def _run_locked() -> int:
|
||||
"""Execute the cron job while holding the lock."""
|
||||
exit_code = 0
|
||||
|
||||
# Step 1: Process due tasks
|
||||
try:
|
||||
results = process_due_tasks()
|
||||
except Exception as e:
|
||||
logger.error(f"Unhandled error in process_due_tasks: {e}", exc_info=True)
|
||||
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
|
||||
return 1
|
||||
|
||||
# Step 2: Run decentralized .daemon/ scheduler tick
|
||||
tick_results = {"fired": 0, "failed": 0}
|
||||
try:
|
||||
tick_results = run_tick()
|
||||
except Exception as e:
|
||||
logger.warning(f"Unhandled error in run_tick: {e}")
|
||||
log(f"WARNING: Unhandled error in scheduler tick: {e}")
|
||||
|
||||
# Step 3: Build summary
|
||||
lines = []
|
||||
|
||||
# Tasks section
|
||||
if results["recovered"]:
|
||||
lines.append(f"Recovered {results['recovered']} stale dispatch(es)")
|
||||
if results["due"] or results["success"]:
|
||||
task_line = f"Tasks: {results['due']} due | {results['success']} sent"
|
||||
if results["failed"]:
|
||||
task_line += f" | {results['failed']} failed"
|
||||
lines.append(task_line)
|
||||
else:
|
||||
lines.append("Tasks: none due")
|
||||
|
||||
# Scheduler tick section
|
||||
if tick_results.get("fired") or tick_results.get("failed"):
|
||||
lines.append(f"Scheduler: {tick_results.get('fired', 0)} fired, {tick_results.get('failed', 0)} failed")
|
||||
else:
|
||||
lines.append(f"Scheduler: {tick_results.get('discovered', 0)} discovered, none due")
|
||||
|
||||
# Next run
|
||||
lines.append(f"Next: ~{_next_cron_run()}")
|
||||
|
||||
summary = "\n".join(lines)
|
||||
|
||||
log(f"Results: {summary}")
|
||||
|
||||
# Step 4: Determine exit code
|
||||
if results["failed"] > 0 or results["errors"] or tick_results.get("failed", 0) > 0:
|
||||
exit_code = 1
|
||||
|
||||
log("Scheduler cron finished")
|
||||
if exit_code == 0:
|
||||
logger.info("[DAEMON] scheduler_cron: Cron cycle completed successfully")
|
||||
log("=" * 60)
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
# Last-resort catch -- never crash silently
|
||||
logger.error(f"FATAL scheduler_cron exception: {e}", exc_info=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
|
||||
sys.exit(1)
|
||||
@@ -265,6 +265,12 @@ def test_help_preempts() -> None:
|
||||
assert result == 0, "--help must return 0 before any module routing"
|
||||
|
||||
|
||||
def test_invalid_mode_raises() -> None:
|
||||
"""invalid_mode_raises: save_json with invalid_type raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
json_handler.save_json("contract_mod", "invalid_type_xyz", {"bad": True})
|
||||
|
||||
|
||||
def test_no_args_triggers() -> None:
|
||||
"""no_args_triggers: no arguments triggers introspection display."""
|
||||
from aipass.daemon.apps import daemon as _daemon_mod
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
# ===================AIPASS====================
|
||||
# META DATA HEADER
|
||||
# Name: test_scheduler_cron.py - Scheduler Cron Tests
|
||||
# Date: 2026-04-02
|
||||
# Version: 1.0.0
|
||||
# Category: daemon/tests
|
||||
#
|
||||
# CHANGELOG (Max 5 entries):
|
||||
# - v1.0.0 (2026-04-02): Initial creation - scheduler_cron dispatch path tests
|
||||
#
|
||||
# CODE STANDARDS:
|
||||
# - Pytest conventions
|
||||
# - Full mock isolation (no real subprocesses or locks)
|
||||
# =============================================
|
||||
|
||||
"""Tests for scheduler_cron dispatch paths."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
MODULE = "aipass.daemon.apps.scheduler_cron"
|
||||
|
||||
|
||||
# =============================================
|
||||
# FIXTURES
|
||||
# =============================================
|
||||
|
||||
|
||||
def _make_task(
|
||||
task_id: str = "abc12345-6789",
|
||||
recipient: str = "@devpulse",
|
||||
task: str = "Run morning briefing",
|
||||
message: str = "Details here",
|
||||
) -> dict:
|
||||
"""Build a minimal task dict for testing."""
|
||||
return {
|
||||
"id": task_id,
|
||||
"recipient": recipient,
|
||||
"task": task,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _silence_logging():
|
||||
"""Suppress logger and console output for all tests."""
|
||||
with (
|
||||
patch(f"{MODULE}.logger"),
|
||||
patch(f"{MODULE}.console"),
|
||||
patch(f"{MODULE}.log"),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# =============================================
|
||||
# _send_email_via_drone
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestSendEmailViaDrone:
|
||||
"""Tests for _send_email_via_drone subprocess wrapper."""
|
||||
|
||||
def test_success(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = _send_email_via_drone("@devpulse", "Subject", "Body")
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:3] == ["drone", "@ai_mail", "send"]
|
||||
assert "--dispatch" in cmd
|
||||
|
||||
def test_no_auto_execute(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
|
||||
_send_email_via_drone("@devpulse", "Subj", "Msg", auto_execute=False)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--dispatch" not in cmd
|
||||
|
||||
def test_nonzero_returncode(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with patch(f"{MODULE}.subprocess.run", return_value=mock_result):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
def test_subprocess_error(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
with patch(f"{MODULE}.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="drone", timeout=15)):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
def test_os_error(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
|
||||
|
||||
with patch(f"{MODULE}.subprocess.run", side_effect=OSError("drone not found")):
|
||||
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================
|
||||
# _next_cron_run
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestNextCronRun:
|
||||
"""Tests for next cron run time calculation."""
|
||||
|
||||
def test_before_half_hour(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 10, 15, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "10:30"
|
||||
|
||||
def test_after_half_hour(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 10, 45, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "11:00"
|
||||
|
||||
def test_before_midnight_rollover(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _next_cron_run
|
||||
|
||||
fake_now = datetime(2026, 4, 2, 23, 45, 0)
|
||||
with patch(f"{MODULE}.datetime") as mock_dt:
|
||||
mock_dt.now.return_value = fake_now
|
||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
||||
result = _next_cron_run()
|
||||
assert result == "00:00"
|
||||
|
||||
|
||||
# =============================================
|
||||
# _process_single_task
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessSingleTask:
|
||||
"""Tests for the single-task dispatch function."""
|
||||
|
||||
def test_success_path(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching") as mock_dispatch,
|
||||
patch(f"{MODULE}.send_email_direct", return_value=True) as mock_send,
|
||||
patch(f"{MODULE}.mark_completed") as mock_complete,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_dispatch.assert_called_once_with(task["id"])
|
||||
mock_send.assert_called_once()
|
||||
mock_complete.assert_called_once_with(task["id"])
|
||||
assert results["success"] == 1
|
||||
assert results["failed"] == 0
|
||||
|
||||
def test_mark_dispatching_failure(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching", side_effect=RuntimeError("lock error")),
|
||||
patch(f"{MODULE}.send_email_direct") as mock_send,
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert results["failed"] == 1
|
||||
assert len(results["errors"]) == 1
|
||||
|
||||
def test_email_unavailable(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
|
||||
def test_email_send_returns_false(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_email_exception_resets_to_pending(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _process_single_task
|
||||
|
||||
results = {"success": 0, "failed": 0, "errors": []}
|
||||
task = _make_task()
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", side_effect=ConnectionError("timeout")),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
_process_single_task(task, results)
|
||||
|
||||
mock_pending.assert_called_once_with(task["id"])
|
||||
assert results["failed"] == 1
|
||||
|
||||
|
||||
# =============================================
|
||||
# process_due_tasks
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestProcessDueTasks:
|
||||
"""Tests for the top-level due-task processor."""
|
||||
|
||||
def test_no_tasks_due(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 0
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_task_registry_unavailable(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", False):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 0
|
||||
assert results["success"] == 0
|
||||
|
||||
def test_stale_dispatch_recovery(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=3) as mock_recover,
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
mock_recover.assert_called_once_with(max_age_minutes=5)
|
||||
assert results["recovered"] == 3
|
||||
|
||||
def test_stale_recovery_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", side_effect=RuntimeError("fs error")),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[]),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert len(results["errors"]) == 1
|
||||
assert "Stale recovery" in results["errors"][0]
|
||||
|
||||
def test_get_due_tasks_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", side_effect=RuntimeError("corrupt JSON")),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert "Load tasks" in results["errors"][0]
|
||||
|
||||
@patch(f"{MODULE}.time.sleep")
|
||||
def test_successful_send(self, _mock_sleep):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
task = _make_task()
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=True),
|
||||
patch(f"{MODULE}.mark_completed"),
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
assert results["due"] == 1
|
||||
assert results["success"] == 1
|
||||
assert results["failed"] == 0
|
||||
|
||||
@patch(f"{MODULE}.time.sleep")
|
||||
def test_send_failure_marks_pending(self, _mock_sleep):
|
||||
from aipass.daemon.apps.scheduler_cron import process_due_tasks
|
||||
|
||||
task = _make_task()
|
||||
with (
|
||||
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
|
||||
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
|
||||
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
|
||||
patch(f"{MODULE}.mark_dispatching"),
|
||||
patch(f"{MODULE}.send_email_direct", return_value=False),
|
||||
patch(f"{MODULE}.mark_pending") as mock_pending,
|
||||
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
|
||||
):
|
||||
results = process_due_tasks()
|
||||
|
||||
mock_pending.assert_called_once()
|
||||
assert results["failed"] == 1
|
||||
|
||||
|
||||
# =============================================
|
||||
# _run_locked
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestRunLocked:
|
||||
"""Tests for the locked orchestration function."""
|
||||
|
||||
def test_success_no_errors(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
|
||||
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", return_value=tick_results),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 0
|
||||
|
||||
def test_returns_1_on_task_failures(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]}
|
||||
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", return_value=tick_results),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 1
|
||||
|
||||
def test_process_due_tasks_unhandled_exception(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
with patch(f"{MODULE}.process_due_tasks", side_effect=RuntimeError("boom")):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 1
|
||||
|
||||
def test_run_tick_exception_handled(self):
|
||||
from aipass.daemon.apps.scheduler_cron import _run_locked
|
||||
|
||||
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
|
||||
patch(f"{MODULE}.run_tick", side_effect=RuntimeError("tick boom")),
|
||||
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
|
||||
):
|
||||
code = _run_locked()
|
||||
|
||||
assert code == 0
|
||||
|
||||
|
||||
# =============================================
|
||||
# main
|
||||
# =============================================
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for the main entry point."""
|
||||
|
||||
def test_no_args_introspection(self):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py"]),
|
||||
patch(f"{MODULE}.print_introspection") as mock_intro,
|
||||
):
|
||||
code = main()
|
||||
|
||||
mock_intro.assert_called_once()
|
||||
assert code == 0
|
||||
|
||||
def test_help_flag(self):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "--help"]),
|
||||
patch(f"{MODULE}.print_help") as mock_help,
|
||||
):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
mock_help.assert_called_once()
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
|
||||
)
|
||||
def test_lock_acquisition_failure(self, tmp_path):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
lock_file = tmp_path / "schedule.lock"
|
||||
mock_fd = MagicMock()
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
|
||||
patch(f"{MODULE}.json_handler"),
|
||||
patch(f"{MODULE}.LOCK_FILE", lock_file),
|
||||
patch("builtins.open", return_value=mock_fd),
|
||||
patch(f"{MODULE}.fcntl.flock", side_effect=OSError("locked")),
|
||||
):
|
||||
code = main()
|
||||
|
||||
assert code == 0 # graceful skip when another instance is running
|
||||
mock_fd.close.assert_called()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32",
|
||||
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
|
||||
)
|
||||
def test_lock_acquired_runs_locked(self, tmp_path):
|
||||
from aipass.daemon.apps.scheduler_cron import main
|
||||
|
||||
lock_file = tmp_path / "schedule.lock"
|
||||
mock_fd = MagicMock()
|
||||
with (
|
||||
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
|
||||
patch(f"{MODULE}.json_handler"),
|
||||
patch(f"{MODULE}.LOCK_FILE", lock_file),
|
||||
patch("builtins.open", return_value=mock_fd),
|
||||
patch(f"{MODULE}.fcntl.flock"),
|
||||
patch(f"{MODULE}._run_locked", return_value=0) as mock_run,
|
||||
):
|
||||
code = main()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert code == 0
|
||||
Reference in New Issue
Block a user