diff --git a/engine/hooks/_runner/dispatch.py b/engine/hooks/_runner/dispatch.py index 35743450..090acbb0 100644 --- a/engine/hooks/_runner/dispatch.py +++ b/engine/hooks/_runner/dispatch.py @@ -424,7 +424,7 @@ def _timeout(_signum, _frame): if isinstance(exc, SystemExit): exit_code = exc.code if isinstance(exc.code, int) else 1 else: - exit_code = 0 + exit_code = 1 stdout = b"" stderr = f"catstack-hook-error {hook}: {type(exc).__name__}: {exc}\n".encode() finally: diff --git a/engine/hooks/_runner/tests/chaos/exit_hook.py b/engine/hooks/_runner/tests/chaos/exit_hook.py new file mode 100644 index 00000000..d1b94404 --- /dev/null +++ b/engine/hooks/_runner/tests/chaos/exit_hook.py @@ -0,0 +1,4 @@ +from __future__ import annotations + +def detect(event): + raise SystemExit(3) diff --git a/engine/hooks/_runner/tests/chaos/hang_hook.py b/engine/hooks/_runner/tests/chaos/hang_hook.py new file mode 100644 index 00000000..8643e797 --- /dev/null +++ b/engine/hooks/_runner/tests/chaos/hang_hook.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import time + + +def detect(event): + time.sleep(30) + return [] diff --git a/engine/hooks/_runner/tests/chaos/ok_hook.py b/engine/hooks/_runner/tests/chaos/ok_hook.py new file mode 100644 index 00000000..c56eb866 --- /dev/null +++ b/engine/hooks/_runner/tests/chaos/ok_hook.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from finding import Finding + + +def detect(event): + hook_name = str(event.get("_catstack_current_hook") or "chaos-ok") + return [ + Finding( + rule_id=f"{hook_name}.spoke", + subject=hook_name, + message=f"{hook_name} sibling spoke", + evidence="chaos fixture", + ) + ] diff --git a/engine/hooks/_runner/tests/chaos/raise_hook.py b/engine/hooks/_runner/tests/chaos/raise_hook.py new file mode 100644 index 00000000..39a4c6d0 --- /dev/null +++ b/engine/hooks/_runner/tests/chaos/raise_hook.py @@ -0,0 +1,5 @@ +from __future__ import annotations + + +def detect(event): + raise RuntimeError("chaos fixture boom") diff --git a/engine/hooks/_runner/tests/test_dispatch.py b/engine/hooks/_runner/tests/test_dispatch.py index 20dc6e02..f96ab1ec 100644 --- a/engine/hooks/_runner/tests/test_dispatch.py +++ b/engine/hooks/_runner/tests/test_dispatch.py @@ -10,6 +10,7 @@ from unittest import mock RUNNER_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CHAOS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "chaos") class DispatchCLI(unittest.TestCase): @@ -55,6 +56,19 @@ def _write_hook( with open(os.path.join(hook_dir, "claude.hook.json"), "w", encoding="utf-8") as handle: json.dump(manifest, handle) + def _write_chaos_sdk_hook(self, name: str, module_file: str) -> None: + with open(os.path.join(CHAOS_DIR, module_file), encoding="utf-8") as handle: + module_body = handle.read() + module_name = os.path.splitext(module_file)[0] + self._write_hook( + name, + "fallback.py", + "raise SystemExit('chaos SDK fixture unexpectedly used subprocess fallback')\n", + event="Stop", + subagent_stop={"dispatch": {"entry": f"{module_name}:detect"}}, + extra_files={module_file: module_body}, + ) + def _env(self) -> dict[str, str]: env = os.environ.copy() env["HOME"] = self.home @@ -261,6 +275,41 @@ def test_sdk_entrypoint_runs_in_process_and_emits_rule_ids(self): self.assertEqual(row["dispatch_path"], "sdk") self.assertEqual(row["rule_ids"], ["fixture.sdk"]) + def test_sdk_chaos_failures_each_emit_rows_without_silencing_siblings(self): + self._install_sdk() + self._write_chaos_sdk_hook("chaos-a-ok", "ok_hook.py") + self._write_chaos_sdk_hook("chaos-b-raise", "raise_hook.py") + self._write_chaos_sdk_hook("chaos-c-hang", "hang_hook.py") + self._write_chaos_sdk_hook("chaos-d-exit", "exit_hook.py") + self._write_chaos_sdk_hook("chaos-e-ok", "ok_hook.py") + mode_env = { + "CATSTACK_HOOK_MODE_CHAOS_A_OK": "warn", + "CATSTACK_HOOK_MODE_CHAOS_E_OK": "warn", + } + env_patch = mock.patch.dict(os.environ, mode_env) + env_patch.start() + self.addCleanup(env_patch.stop) + + result = self._run(event="SubagentStop", timeout="1") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(b"chaos-ok sibling spoke", result.stdout) + rows = {row["hook"]: row for row in self._rows()} + self.assertEqual( + set(rows), + {"chaos-a-ok", "chaos-b-raise", "chaos-c-hang", "chaos-d-exit", "chaos-e-ok"}, + ) + self.assertEqual(rows["chaos-a-ok"]["outcome"], "spoke") + self.assertEqual(rows["chaos-e-ok"]["outcome"], "spoke") + self.assertEqual(rows["chaos-b-raise"]["outcome"], "crashed") + self.assertEqual(rows["chaos-c-hang"]["outcome"], "timed_out") + self.assertEqual(rows["chaos-d-exit"]["outcome"], "crashed") + self.assertEqual(rows["chaos-b-raise"]["exit_code"], 1) + self.assertEqual(rows["chaos-c-hang"]["exit_code"], 1) + self.assertEqual(rows["chaos-d-exit"]["exit_code"], 3) + self.assertEqual(rows["chaos-b-raise"]["dispatch_path"], "sdk_error") + self.assertEqual(rows["chaos-c-hang"]["dispatch_path"], "sdk_error") + self.assertEqual(rows["chaos-d-exit"]["dispatch_path"], "sdk_error") FIXTURES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") FIXTURE_HOOK = "fixture-extra-manifest"