diff --git a/CHANGELOG.md b/CHANGELOG.md index 145ba7c9..199e9a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ PyPI version — not the changelog header. --- +## [2026-07-16] + +### Added + +- **Close pipeline completes itself (DPLAN-0245): auto-vectorization + + crash-safe registry writes + drone timeout policy.** Closing a plan now + produces all side effects from one command — `post_close_runner` invokes + @memory's plan intake directly after archival (detached, loud on failure, + drains any backlog it finds), so plans can no longer silently pile up + unvectorized. Plan registry saves (@flow `save_registry` + mbank + `save_flow_registry`) now use the O_EXCL lockfile + atomic + tempfile-and-replace pattern, closing the same lost-update race class fixed + earlier in CLOSED_PLANS. @drone gained a 3-layer timeout policy: per-command + overrides (`@memory process-plans` 120s, `@flow close` 90s), a `--timeout N` + flag, 30s default — replacing the flat 30s guillotine that killed legitimate + long commands mid-pipeline; the timeout error now says how to override. + Proven end-to-end live: one `drone @flow close` on a throwaway plan yielded + archive + vectors + ledger + registry with zero manual steps, and the + auto-trigger swept a pre-existing backlog file on its first run. 730 flow + + 878 drone tests green, seedgo 100%. + ## [2026-07-15] ### Fixed diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 82c52c3e..01604f24 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -51,6 +51,21 @@ INTERACTIVE_COMMANDS = ("monitor", "audit", "watchdog", "status") INTERACTIVE_BRANCHES = ("cli", "backup") +def _extract_timeout(args: list[str]) -> tuple[list[str], int | None]: + """Extract --timeout N from an arg list. Returns (cleaned_args, timeout_or_None).""" + if "--timeout" not in args: + return args, None + idx = args.index("--timeout") + if idx + 1 >= len(args): + return args, None + try: + timeout = int(args[idx + 1]) + except ValueError: + logger.info("--timeout value %r is not an integer, ignoring", args[idx + 1]) + return args, None + return args[:idx] + args[idx + 2 :], timeout + + # ============================================================================= # AUTO-DISCOVERY # ============================================================================= @@ -92,6 +107,7 @@ def show_help() -> None: table.add_row("list", "List registered custom commands") table.add_row("remove ", "Remove a custom command") table.add_row("rm [...]", "Contained safe-delete (project + tmp)") + table.add_row("--timeout ", "Override subprocess timeout (default 30s)") table.add_row("--help", "Show this help") table.add_row("--version", "Show version") @@ -340,6 +356,7 @@ def _handle_custom_command(args: list[str]) -> int: target = cmd_data["target"] command = cmd_data["command"] cmd_args = list(cmd_data.get("args", [])) + remaining_args + cmd_args, explicit_timeout = _extract_timeout(cmd_args) module_name = target.lstrip("@").lower() interactive = command in INTERACTIVE_COMMANDS or module_name in INTERACTIVE_BRANCHES @@ -349,6 +366,7 @@ def _handle_custom_command(args: list[str]) -> int: target, command, args=cmd_args if cmd_args else None, + timeout=explicit_timeout, interactive=interactive, ) except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: @@ -412,6 +430,7 @@ def _handle_target(args: List[str]) -> int: """Handle `drone @target command [args]` or `drone @target --help`.""" target = args[0] rest = args[1:] + rest, explicit_timeout = _extract_timeout(rest) module_name = target.lstrip("@").lower() first_cmd = rest[0] if rest and rest[0] not in ("--help", "-h") else None @@ -470,6 +489,7 @@ def _handle_target(args: List[str]) -> int: target, command, args=cmd_args if cmd_args else None, + timeout=explicit_timeout, interactive=interactive, ) except (BranchNotFoundError, CommandExecutionError, RegistryError) as exc: diff --git a/src/aipass/drone/apps/handlers/executor.py b/src/aipass/drone/apps/handlers/executor.py index cecce70e..a469bd59 100644 --- a/src/aipass/drone/apps/handlers/executor.py +++ b/src/aipass/drone/apps/handlers/executor.py @@ -21,6 +21,29 @@ from .exceptions import CommandExecutionError from aipass.drone.apps.handlers.json import json_handler +DEFAULT_TIMEOUT = 30 + +TIMEOUT_OVERRIDES: dict[str, dict[str, int]] = { + "memory": {"process-plans": 120}, + "flow": {"close": 90}, +} + + +def resolve_timeout(branch: str, command: str | None, explicit: int | None = None) -> int: + """Resolve subprocess timeout for a branch command. + + Priority: explicit flag > per-command policy > DEFAULT_TIMEOUT. + """ + if explicit is not None: + return explicit + branch_key = branch.lstrip("@").lower() + if command and branch_key in TIMEOUT_OVERRIDES: + cmd_timeout = TIMEOUT_OVERRIDES[branch_key].get(command) + if cmd_timeout is not None: + return cmd_timeout + return DEFAULT_TIMEOUT + + @dataclass class CommandResult: """Result of a routed command execution.""" @@ -82,7 +105,10 @@ def execute_command( return CommandResult(stdout="", stderr="", exit_code=130, branch="", command="") raise except subprocess.TimeoutExpired as e: - raise CommandExecutionError(f"Command timed out after {timeout}s: {' '.join(full_cmd)}") from e + raise CommandExecutionError( + f"Command timed out after {timeout}s: {' '.join(full_cmd)}\n" + f" Override with: drone @ --timeout " + ) from e except FileNotFoundError as e: raise CommandExecutionError(f"Executable not found: {executable!r}") from e except OSError as e: diff --git a/src/aipass/drone/apps/modules/router.py b/src/aipass/drone/apps/modules/router.py index 68a30191..90a19d36 100644 --- a/src/aipass/drone/apps/modules/router.py +++ b/src/aipass/drone/apps/modules/router.py @@ -20,7 +20,7 @@ from typing import Dict, List, Optional from aipass.prax.apps.modules.logger import system_logger from aipass.cli.apps.modules import console -from aipass.drone.apps.handlers.executor import CommandResult +from aipass.drone.apps.handlers.executor import CommandResult, resolve_timeout from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.router_handler import ( detect_caller_branch_name, @@ -90,28 +90,38 @@ def route_command( target: str, command: Optional[str] = None, args: Optional[List[str]] = None, - timeout: int = 30, + timeout: int | None = None, interactive: bool = False, ) -> CommandResult: """Route a command to a branch's entry point. Resolves @target to a path, then delegates to the handler for execution. When command is None, runs the branch with no args (introspection). + + Timeout resolution: explicit value > per-command policy > DEFAULT_TIMEOUT. """ branch_path = resolve_branch(target) branch_name = target.lstrip("@").lower() + resolved_timeout = resolve_timeout(branch_name, command, timeout) caller = detect_caller_branch_name(Path.cwd()) if not caller: caller = os.environ.get("AIPASS_BRANCH_NAME") caller_tag = f" [CALLER:{caller.upper()}]" if caller else "" - logger.info("Routing @%s%s → %s %s", branch_name, caller_tag, command or "(introspection)", args or []) + logger.info( + "Routing @%s%s → %s %s (timeout=%ds)", + branch_name, + caller_tag, + command or "(introspection)", + args or [], + resolved_timeout, + ) return execute_branch_command( branch_path=branch_path, branch_name=branch_name, command=command, args=args, - timeout=timeout, + timeout=resolved_timeout, interactive=interactive, ) @@ -147,7 +157,7 @@ def print_introspection(): def route_all( command: str, args: Optional[List[str]] = None, - timeout: int = 30, + timeout: int | None = None, ) -> Dict[str, CommandResult]: """Route the same command to ALL active branches in the registry.""" if args is None: diff --git a/src/aipass/drone/tests/test_activation.py b/src/aipass/drone/tests/test_activation.py index ccaf4e7c..7b29bd85 100644 --- a/src/aipass/drone/tests/test_activation.py +++ b/src/aipass/drone/tests/test_activation.py @@ -355,6 +355,7 @@ class TestHandleCustomCommand: "@seedgo", "audit", args=["aipass"], + timeout=None, interactive=True, ) @@ -380,6 +381,7 @@ class TestHandleCustomCommand: "@seedgo", "audit", args=["aipass", "@drone"], + timeout=None, interactive=True, ) @@ -616,6 +618,7 @@ class TestMainIntegration: "@seedgo", "audit", args=["aipass"], + timeout=None, interactive=True, ) @@ -642,6 +645,7 @@ class TestMainIntegration: "@seedgo", "audit", args=["aipass", "@drone"], + timeout=None, interactive=True, ) @@ -714,5 +718,6 @@ class TestMatchCommandIntegration: "@flow", "create", args=["--type=plan", "my-plan"], + timeout=None, interactive=False, ) diff --git a/src/aipass/drone/tests/test_cli_routing.py b/src/aipass/drone/tests/test_cli_routing.py index 872f437d..0ae425f5 100644 --- a/src/aipass/drone/tests/test_cli_routing.py +++ b/src/aipass/drone/tests/test_cli_routing.py @@ -903,3 +903,71 @@ class TestAipassIntercept: ): result = main() assert result == 0 + + +# --------------------------------------------------------------------------- +# _extract_timeout — --timeout flag parsing +# --------------------------------------------------------------------------- + + +class TestExtractTimeout: + """Tests for --timeout flag extraction from arg lists.""" + + def test_no_flag(self) -> None: + """Args without --timeout pass through unchanged.""" + from aipass.drone.apps.drone import _extract_timeout + + args = ["close", "FPLAN-0313"] + cleaned, timeout = _extract_timeout(args) + assert cleaned == ["close", "FPLAN-0313"] + assert timeout is None + + def test_flag_at_end(self) -> None: + """--timeout N at end of args is extracted.""" + from aipass.drone.apps.drone import _extract_timeout + + cleaned, timeout = _extract_timeout(["process-plans", "--timeout", "120"]) + assert cleaned == ["process-plans"] + assert timeout == 120 + + def test_flag_at_start(self) -> None: + """--timeout N at start of args is extracted.""" + from aipass.drone.apps.drone import _extract_timeout + + cleaned, timeout = _extract_timeout(["--timeout", "90", "close", "FPLAN-0313"]) + assert cleaned == ["close", "FPLAN-0313"] + assert timeout == 90 + + def test_flag_in_middle(self) -> None: + """--timeout N in the middle of args is extracted.""" + from aipass.drone.apps.drone import _extract_timeout + + cleaned, timeout = _extract_timeout(["close", "--timeout", "60", "FPLAN-0313"]) + assert cleaned == ["close", "FPLAN-0313"] + assert timeout == 60 + + def test_flag_without_value(self) -> None: + """--timeout at end with no value returns None and leaves args.""" + from aipass.drone.apps.drone import _extract_timeout + + args = ["close", "--timeout"] + cleaned, timeout = _extract_timeout(args) + assert cleaned == args + assert timeout is None + + def test_flag_non_integer_value(self) -> None: + """--timeout with non-integer value returns None and leaves args.""" + from aipass.drone.apps.drone import _extract_timeout + + args = ["close", "--timeout", "abc"] + cleaned, timeout = _extract_timeout(args) + assert cleaned == args + assert timeout is None + + def test_empty_args(self) -> None: + """Empty arg list returns empty with None timeout.""" + from aipass.drone.apps.drone import _extract_timeout + + cleaned, timeout = _extract_timeout([]) + assert cleaned == [] + assert timeout is None diff --git a/src/aipass/drone/tests/test_executor.py b/src/aipass/drone/tests/test_executor.py index 1fe3b824..e78bdec6 100644 --- a/src/aipass/drone/tests/test_executor.py +++ b/src/aipass/drone/tests/test_executor.py @@ -8,7 +8,12 @@ from unittest.mock import patch import pytest from aipass.drone.apps.handlers.exceptions import CommandExecutionError -from aipass.drone.apps.handlers.executor import execute_command +from aipass.drone.apps.handlers.executor import ( + DEFAULT_TIMEOUT, + TIMEOUT_OVERRIDES, + execute_command, + resolve_timeout, +) # --------------------------------------------------------------------------- @@ -389,3 +394,63 @@ class TestShellSecurity: # The semicolon is treated as literal text, not a shell separator assert result.stdout.strip() == "hello; echo pwned" assert result.exit_code == 0 + + +# --------------------------------------------------------------------------- +# 11. resolve_timeout — policy resolution +# --------------------------------------------------------------------------- + + +class TestResolveTimeout: + """Timeout resolution: explicit > policy > default.""" + + def test_default_timeout(self): + """Unknown branch+command returns DEFAULT_TIMEOUT.""" + assert resolve_timeout("unknown", "whatever") == DEFAULT_TIMEOUT + + def test_policy_override(self): + """Known branch+command returns the policy value.""" + for branch, cmds in TIMEOUT_OVERRIDES.items(): + for cmd, expected in cmds.items(): + assert resolve_timeout(branch, cmd) == expected + + def test_explicit_wins_over_policy(self): + """Explicit timeout overrides the policy map.""" + branch = next(iter(TIMEOUT_OVERRIDES)) + cmd = next(iter(TIMEOUT_OVERRIDES[branch])) + assert resolve_timeout(branch, cmd, explicit=999) == 999 + + def test_explicit_wins_over_default(self): + """Explicit timeout overrides the default.""" + assert resolve_timeout("unknown", "whatever", explicit=42) == 42 + + def test_none_command_returns_default(self): + """None command (introspection) returns default.""" + assert resolve_timeout("memory", None) == DEFAULT_TIMEOUT + + def test_at_prefix_stripped(self): + """Leading @ on branch name is stripped before lookup.""" + for branch in TIMEOUT_OVERRIDES: + cmd = next(iter(TIMEOUT_OVERRIDES[branch])) + expected = TIMEOUT_OVERRIDES[branch][cmd] + assert resolve_timeout(f"@{branch}", cmd) == expected + + +# --------------------------------------------------------------------------- +# 12. Timeout error message includes --timeout hint +# --------------------------------------------------------------------------- + + +class TestTimeoutErrorMessage: + """Timeout error tells the caller how to override.""" + + def test_timeout_error_includes_override_hint(self, temp_test_dir: Path): + """The timeout error message mentions --timeout.""" + with pytest.raises(CommandExecutionError, match="--timeout") as exc_info: + execute_command( + sys.executable, + ["-c", "import time; time.sleep(10)"], + cwd=str(temp_test_dir), + timeout=1, + ) + assert "--timeout" in str(exc_info.value)