From 223cde1b941a6ead9d6435e11235010ee6222c05 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 10:29:03 -0400 Subject: [PATCH 1/3] feat: terminal launcher diagnostics and mutation preflight (Closes #556) Add categorize-and-probe helpers for shell spawn failures, expose gitea_diagnose_terminal, and fail closed on mutation capability resolve when the terminal launcher is unhealthy (BLOCKED + DIAGNOSE). Document the operator path in the workflow runbooks. --- docs/llm-workflow-runbooks.md | 15 +++ gitea_mcp_server.py | 67 +++++++++++ native_mcp_preference.py | 155 +++++++++++++++++++++++++- tests/test_native_mcp_preference.py | 64 ++++++++++- tests/test_resolve_task_capability.py | 41 +++++++ 5 files changed, 340 insertions(+), 2 deletions(-) diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index ea2f64e..2a91259 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -661,6 +661,21 @@ session, clearing hung background terminals, switching to MCP-native commit, and the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a loop and do **not** substitute WebFetch/Playwright/manual base64. +### Terminal launcher diagnostics (#556) + +When git/pytest finalization fails with opaque spawn errors (e.g. `os error 2`), +do **not** improvise shell wrappers or fall back to direct API / temp scripts. + +1. Call `gitea_diagnose_terminal` (optional `cwd`, optional `command`) — returns + categorized failure: missing cwd, cwd not a directory, missing executable, + missing runtime wrapper, missing shell, probe timeout, or session launcher + failure. +2. Call `gitea_get_shell_health` for the shell circuit-breaker state. +3. Mutation-capable `gitea_resolve_task_capability` probes the terminal launcher + before allowing git/pytest-oriented work; on failure it returns + `BLOCKED + DIAGNOSE` with `terminal_launcher_unhealthy` and diagnostics. +4. Emit the canonical `blocked-diagnose-report.md` template and stop. + ### Create an issue / child issues - **Profile:** issue-manager or author (any profile allowed to create issues). diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 099021f..0c05920 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -7906,6 +7906,36 @@ def gitea_get_shell_health() -> dict: return native_mcp_preference.shell_health_status() +@mcp.tool() +def gitea_diagnose_terminal( + cwd: str | None = None, + command: list[str] | None = None, +) -> dict: + """Read-only: Run active diagnostics on terminal launcher spawn ability (#556). + + Args: + cwd: Active worktree or current directory to test (defaults to GITEA_ACTIVE_WORKTREE). + command: Test command to attempt (defaults to ['git', '--version'] or ['echo', 'ok']). + """ + resolved_cwd = cwd or os.environ.get("GITEA_ACTIVE_WORKTREE") + if resolved_cwd: + resolved_cwd = os.path.realpath(resolved_cwd) + + probe_cmd = command or native_mcp_preference.default_terminal_probe_command() + + diag = native_mcp_preference.diagnose_terminal_failure(resolved_cwd, probe_cmd) + probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd, probe_cmd) + + return { + "healthy": probe_res["healthy"], + "error_type": None if probe_res["healthy"] else probe_res["error_type"] or diag["error_type"], + "error_msg": "" if probe_res["healthy"] else probe_res["error_msg"] or diag["error_msg"], + "cwd": resolved_cwd, + "command": probe_cmd, + "diagnostics": diag, + } + + @mcp.tool() def gitea_validate_review_final_report( report_text: str, @@ -9412,6 +9442,43 @@ def gitea_resolve_task_capability( "exact_safe_next_action": next_safe_action, } + # ── Terminal Launcher Preflight Check (#556) ── + if task in native_mcp_preference.GITEA_MUTATION_TASKS: + in_test = _preflight_in_test_mode() + if not in_test or os.environ.get("GITEA_FORCE_TERMINAL_PROBE") == "1": + resolved_cwd = os.environ.get("GITEA_ACTIVE_WORKTREE") or PROJECT_ROOT + probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd) + if not probe_res["healthy"]: + profile = get_profile() + h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) + username = _authenticated_username(h) if h else None + next_safe_action = ( + "BLOCKED + DIAGNOSE: terminal-launcher-failure " + f"({probe_res['error_type']}): {probe_res['error_msg']}. " + "Do not attempt git/pytest finalization or unsafe fallbacks. " + "Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop." + ) + return { + "requested_task": task, + "required_operation_permission": required_permission, + "required_role_kind": required_role, + "active_profile": profile["profile_name"], + "active_identity": username, + "active_profile_allowed_operations": profile.get("allowed_operations") or [], + "allowed_in_current_session": False, + "stop_required": True, + "infra_stop": True, + "blocked": True, + "blocked_reason": "BLOCKED + DIAGNOSE", + "terminal_launcher_unhealthy": True, + "terminal_launcher_diagnostics": probe_res, + "task_role_guidance": [next_safe_action], + "matching_configured_profile": [], + "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(), + "different_mcp_namespace_required": False, + "exact_safe_next_action": next_safe_action, + } + record_preflight_check("capability", required_role, resolved_task=task) # Try automatic dispatch switching diff --git a/native_mcp_preference.py b/native_mcp_preference.py index 872b800..2e3d1ee 100644 --- a/native_mcp_preference.py +++ b/native_mcp_preference.py @@ -8,6 +8,9 @@ available unless explicit recovery-mode proof is supplied. from __future__ import annotations import re +import os +import shutil +import subprocess from typing import Any from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES @@ -366,4 +369,154 @@ def assess_gitea_operation_path( else "proceed with native MCP" ) ), - } \ No newline at end of file + } + + +def default_terminal_probe_command() -> list[str]: + return ["git", "--version"] if shutil.which("git") else ["echo", "ok"] + + +def _resolve_executable(cwd: str | None, executable: str | None) -> str | None: + if not executable: + return None + if os.path.isabs(executable): + return executable if os.path.exists(executable) and os.access(executable, os.X_OK) else None + if "/" in executable or "\\" in executable: + target_cwd = cwd or os.getcwd() + full_path = os.path.abspath(os.path.join(target_cwd, executable)) + return full_path if os.path.exists(full_path) and os.access(full_path, os.X_OK) else None + return shutil.which(executable) + + +def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, Any]: + """Inspect and categorize command spawn failures (#556).""" + diagnostics = { + "cwd": cwd, + "command": command, + "cwd_exists": True, + "cwd_is_dir": True, + "shell_exists": True, + "executable_exists": True, + "resolved_executable": None, + "error_type": "session launcher failure", + "error_msg": ( + "Subprocess spawn failed despite valid CWD and executable. This may be due " + "to sandboxing, permission restrictions, or system resource limits." + ), + } + + if cwd: + if not os.path.exists(cwd): + diagnostics["cwd_exists"] = False + diagnostics["error_type"] = "missing cwd" + diagnostics["error_msg"] = f"Current working directory does not exist: {cwd}" + return diagnostics + if not os.path.isdir(cwd): + diagnostics["cwd_is_dir"] = False + diagnostics["error_type"] = "cwd is not a directory" + diagnostics["error_msg"] = f"Current working directory is not a directory: {cwd}" + return diagnostics + + exe = command[0] if command else None + if exe: + resolved_exe = _resolve_executable(cwd, exe) + diagnostics["resolved_executable"] = resolved_exe + if not resolved_exe: + diagnostics["executable_exists"] = False + if "venv" in exe or "pytest" in exe: + diagnostics["error_type"] = "missing runtime wrapper" + diagnostics["error_msg"] = ( + "Virtual environment runtime wrapper/executable not found or not " + f"executable: {exe}" + ) + else: + diagnostics["error_type"] = "missing executable" + diagnostics["error_msg"] = f"Executable not found in PATH or CWD: {exe}" + return diagnostics + + # Check common shell availability + has_any_shell = False + for shell_path in ("/bin/sh", "/bin/zsh", "/bin/bash"): + if os.path.exists(shell_path) and os.access(shell_path, os.X_OK): + has_any_shell = True + break + if not has_any_shell: + diagnostics["shell_exists"] = False + diagnostics["error_type"] = "missing shell" + diagnostics["error_msg"] = "No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is available or executable." + return diagnostics + + return diagnostics + + +def probe_terminal_spawn( + cwd: str | None = None, + command: list[str] | None = None, +) -> dict[str, Any]: + """Proactively probe command spawning to detect launcher health (#556).""" + probe_cmd = command or default_terminal_probe_command() + diag = diagnose_terminal_failure(cwd, probe_cmd) + if diag["error_type"] != "session launcher failure": + return { + "healthy": False, + "error_type": diag["error_type"], + "error_msg": diag["error_msg"], + "cwd": cwd, + "command": probe_cmd, + "diagnostics": diag, + } + + try: + proc = subprocess.run( + probe_cmd, + capture_output=True, + text=True, + cwd=cwd, + timeout=5, + ) + if proc.returncode == 0: + return { + "healthy": True, + "error_type": None, + "error_msg": "", + "cwd": cwd, + "command": probe_cmd, + "diagnostics": diag, + } + # Non-zero status still proves the launcher spawned the process. + return { + "healthy": True, + "error_type": None, + "error_msg": "", + "cwd": cwd, + "command": probe_cmd, + "exit_code": proc.returncode, + "diagnostics": diag, + } + except FileNotFoundError: + return { + "healthy": False, + "error_type": diag["error_type"], + "error_msg": diag["error_msg"], + "cwd": cwd, + "command": probe_cmd, + "diagnostics": diag, + } + except subprocess.TimeoutExpired as exc: + return { + "healthy": False, + "error_type": "probe timeout", + "error_msg": f"Subprocess probe timed out after {exc.timeout} seconds.", + "cwd": cwd, + "command": probe_cmd, + "diagnostics": diag, + } + except Exception as exc: + return { + "healthy": False, + "error_type": diag["error_type"], + "error_msg": f"Subprocess spawn failed with exception: {exc}", + "cwd": cwd, + "command": probe_cmd, + "diagnostics": diag, + } diff --git a/tests/test_native_mcp_preference.py b/tests/test_native_mcp_preference.py index 7607814..82ec6f0 100644 --- a/tests/test_native_mcp_preference.py +++ b/tests/test_native_mcp_preference.py @@ -26,6 +26,68 @@ class TestShellHealthCircuitBreaker(unittest.TestCase): self.assertEqual(status["consecutive_spawn_failures"], 0) +class TestTerminalDiagnostics(unittest.TestCase): + def test_diagnose_missing_cwd(self): + res = nmp.diagnose_terminal_failure("/nonexistent_directory_for_test", ["git", "status"]) + self.assertFalse(res["cwd_exists"]) + self.assertEqual(res["error_type"], "missing cwd") + self.assertIn("does not exist", res["error_msg"]) + + def test_diagnose_cwd_is_not_dir(self): + import tempfile + import os + with tempfile.NamedTemporaryFile() as tmp: + res = nmp.diagnose_terminal_failure(tmp.name, ["git", "status"]) + self.assertFalse(res["cwd_is_dir"]) + self.assertEqual(res["error_type"], "cwd is not a directory") + + def test_diagnose_missing_executable(self): + res = nmp.diagnose_terminal_failure(None, ["nonexistent_exec_for_test"]) + self.assertFalse(res["executable_exists"]) + self.assertEqual(res["error_type"], "missing executable") + + def test_diagnose_missing_runtime_wrapper(self): + res = nmp.diagnose_terminal_failure("/tmp", ["venv/bin/pytest"]) + self.assertFalse(res["executable_exists"]) + self.assertEqual(res["error_type"], "missing runtime wrapper") + + def test_diagnose_relative_executable_from_cwd(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "tool" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + os.chmod(script, 0o755) + + res = nmp.diagnose_terminal_failure(tmp, ["./tool"]) + self.assertTrue(res["executable_exists"]) + self.assertEqual(res["resolved_executable"], str(script)) + + def test_probe_terminal_spawn_success(self): + res = nmp.probe_terminal_spawn() + self.assertTrue(res["healthy"]) + self.assertIn("command", res) + self.assertIn("diagnostics", res) + + def test_probe_terminal_spawn_missing_cwd(self): + res = nmp.probe_terminal_spawn("/nonexistent_directory_for_test") + self.assertFalse(res["healthy"]) + self.assertEqual(res["error_type"], "missing cwd") + + def test_probe_terminal_spawn_honors_custom_command(self): + res = nmp.probe_terminal_spawn(command=[sys.executable, "-c", "raise SystemExit(3)"]) + self.assertTrue(res["healthy"]) + self.assertEqual(res["command"][0], sys.executable) + self.assertEqual(res["exit_code"], 3) + + def test_probe_terminal_spawn_blocks_missing_custom_command(self): + res = nmp.probe_terminal_spawn(command=["nonexistent_exec_for_test"]) + self.assertFalse(res["healthy"]) + self.assertEqual(res["error_type"], "missing executable") + self.assertEqual(res["command"], ["nonexistent_exec_for_test"]) + + class TestNativeMcpPreferenceGate(unittest.TestCase): def setUp(self): nmp.clear_shell_health_for_tests() @@ -118,4 +180,4 @@ class TestNativeMcpPreferenceGate(unittest.TestCase): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_resolve_task_capability.py b/tests/test_resolve_task_capability.py index aa25ced..cdb2e9d 100644 --- a/tests/test_resolve_task_capability.py +++ b/tests/test_resolve_task_capability.py @@ -96,6 +96,14 @@ class TestResolveTaskCapability(unittest.TestCase): "GITEA_TOKEN_MERGER": "merger-pass", } + def test_diagnose_terminal_success_has_no_error(self): + res = mcp_server.gitea_diagnose_terminal( + command=[sys.executable, "-c", "raise SystemExit(0)"] + ) + self.assertTrue(res["healthy"]) + self.assertIsNone(res["error_type"]) + self.assertEqual(res["error_msg"], "") + @patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.get_auth_header", return_value="token author-pass") def test_resolve_author_capable_task(self, _auth, _api): @@ -110,6 +118,39 @@ class TestResolveTaskCapability(unittest.TestCase): self.assertFalse(res["different_mcp_namespace_required"]) self.assertIn("ready for operations", res["exact_safe_next_action"]) + @patch("mcp_server.native_mcp_preference.probe_terminal_spawn") + @patch("mcp_server.api_request", return_value={"login": "author-user"}) + @patch("mcp_server.get_auth_header", return_value="token author-pass") + def test_resolve_mutation_task_blocks_when_terminal_launcher_unhealthy( + self, + _auth, + _api, + mock_probe, + ): + mock_probe.return_value = { + "healthy": False, + "error_type": "missing cwd", + "error_msg": "Current working directory does not exist: /missing", + "cwd": "/missing", + "command": ["git", "--version"], + "diagnostics": {"cwd_exists": False}, + } + env = self._env("author-profile") + # Probe is skipped under pytest unless forced (mirrors production path). + env["GITEA_FORCE_TERMINAL_PROBE"] = "1" + with patch.dict(os.environ, env): + res = mcp_server.gitea_resolve_task_capability(task="create_issue", remote="prgs") + + self.assertFalse(res["allowed_in_current_session"]) + self.assertTrue(res["stop_required"]) + self.assertTrue(res["infra_stop"]) + self.assertTrue(res["terminal_launcher_unhealthy"]) + self.assertTrue(res["blocked"]) + self.assertEqual(res["blocked_reason"], "BLOCKED + DIAGNOSE") + self.assertEqual(res["terminal_launcher_diagnostics"]["error_type"], "missing cwd") + self.assertIn("terminal-launcher-failure", res["exact_safe_next_action"]) + self.assertIn("BLOCKED + DIAGNOSE", res["exact_safe_next_action"]) + @patch("mcp_server.api_request", return_value={"login": "author-user"}) @patch("mcp_server.get_auth_header", return_value="token author-pass") def test_resolve_reviewer_capable_task_blocked(self, _auth, _api): From f558b80cc8641fd60c8cb1f4ea9f9beee5f32f72 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 10:43:42 -0400 Subject: [PATCH 2/3] test: cover explicit git -C terminal probe --- gitea_mcp_server.py | 12 ++++++++++-- native_mcp_preference.py | 13 ++++++++++--- tests/test_native_mcp_preference.py | 25 ++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 0c05920..c960185 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -7928,8 +7928,16 @@ def gitea_diagnose_terminal( return { "healthy": probe_res["healthy"], - "error_type": None if probe_res["healthy"] else probe_res["error_type"] or diag["error_type"], - "error_msg": "" if probe_res["healthy"] else probe_res["error_msg"] or diag["error_msg"], + "error_type": ( + None + if probe_res["healthy"] + else probe_res["error_type"] or diag["error_type"] + ), + "error_msg": ( + "" + if probe_res["healthy"] + else probe_res["error_msg"] or diag["error_msg"] + ), "cwd": resolved_cwd, "command": probe_cmd, "diagnostics": diag, diff --git a/native_mcp_preference.py b/native_mcp_preference.py index 2e3d1ee..60cb420 100644 --- a/native_mcp_preference.py +++ b/native_mcp_preference.py @@ -380,11 +380,15 @@ def _resolve_executable(cwd: str | None, executable: str | None) -> str | None: if not executable: return None if os.path.isabs(executable): - return executable if os.path.exists(executable) and os.access(executable, os.X_OK) else None + if os.path.exists(executable) and os.access(executable, os.X_OK): + return executable + return None if "/" in executable or "\\" in executable: target_cwd = cwd or os.getcwd() full_path = os.path.abspath(os.path.join(target_cwd, executable)) - return full_path if os.path.exists(full_path) and os.access(full_path, os.X_OK) else None + if os.path.exists(full_path) and os.access(full_path, os.X_OK): + return full_path + return None return shutil.which(executable) @@ -443,7 +447,10 @@ def diagnose_terminal_failure(cwd: str | None, command: list[str]) -> dict[str, if not has_any_shell: diagnostics["shell_exists"] = False diagnostics["error_type"] = "missing shell" - diagnostics["error_msg"] = "No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is available or executable." + diagnostics["error_msg"] = ( + "No standard system shell (/bin/sh, /bin/zsh, /bin/bash) is " + "available or executable." + ) return diagnostics return diagnostics diff --git a/tests/test_native_mcp_preference.py b/tests/test_native_mcp_preference.py index 82ec6f0..9566759 100644 --- a/tests/test_native_mcp_preference.py +++ b/tests/test_native_mcp_preference.py @@ -1,6 +1,7 @@ """Tests for native MCP preference gate and shell circuit breaker (#270).""" import sys import unittest +import shutil from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) @@ -76,11 +77,33 @@ class TestTerminalDiagnostics(unittest.TestCase): self.assertEqual(res["error_type"], "missing cwd") def test_probe_terminal_spawn_honors_custom_command(self): - res = nmp.probe_terminal_spawn(command=[sys.executable, "-c", "raise SystemExit(3)"]) + res = nmp.probe_terminal_spawn( + command=[sys.executable, "-c", "raise SystemExit(3)"] + ) self.assertTrue(res["healthy"]) self.assertEqual(res["command"][0], sys.executable) self.assertEqual(res["exit_code"], 3) + @unittest.skipUnless(shutil.which("git"), "git is required for git -C probe") + def test_probe_terminal_spawn_supports_explicit_git_c_worktree(self): + import subprocess + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + subprocess.run( + ["git", "init", tmp], + check=True, + capture_output=True, + text=True, + ) + res = nmp.probe_terminal_spawn( + cwd="/", + command=["git", "-C", tmp, "status", "--short"], + ) + + self.assertTrue(res["healthy"]) + self.assertEqual(res["command"][1], "-C") + def test_probe_terminal_spawn_blocks_missing_custom_command(self): res = nmp.probe_terminal_spawn(command=["nonexistent_exec_for_test"]) self.assertFalse(res["healthy"]) From 8d6d3cb014ed26bacc63ad336fb8b97c3d3bd32b Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 11:31:48 -0400 Subject: [PATCH 3/3] fix: refine terminal preflight check to avoid false-positive PATH blocks on server --- gitea_mcp_server.py | 63 +++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index c960185..241b278 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -9457,35 +9457,42 @@ def gitea_resolve_task_capability( resolved_cwd = os.environ.get("GITEA_ACTIVE_WORKTREE") or PROJECT_ROOT probe_res = native_mcp_preference.probe_terminal_spawn(resolved_cwd) if not probe_res["healthy"]: - profile = get_profile() - h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) - username = _authenticated_username(h) if h else None - next_safe_action = ( - "BLOCKED + DIAGNOSE: terminal-launcher-failure " - f"({probe_res['error_type']}): {probe_res['error_msg']}. " - "Do not attempt git/pytest finalization or unsafe fallbacks. " - "Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop." + # Only block if the failure is due to a missing CWD, missing shell, or the circuit breaker is tripped. + # Other failures (like missing executable in the MCP process's limited PATH) should not block the agent's shell capability. + is_real_blocker = ( + probe_res.get("error_type") in ("missing cwd", "missing shell") + or native_mcp_preference.shell_health_status().get("hard_stopped") ) - return { - "requested_task": task, - "required_operation_permission": required_permission, - "required_role_kind": required_role, - "active_profile": profile["profile_name"], - "active_identity": username, - "active_profile_allowed_operations": profile.get("allowed_operations") or [], - "allowed_in_current_session": False, - "stop_required": True, - "infra_stop": True, - "blocked": True, - "blocked_reason": "BLOCKED + DIAGNOSE", - "terminal_launcher_unhealthy": True, - "terminal_launcher_diagnostics": probe_res, - "task_role_guidance": [next_safe_action], - "matching_configured_profile": [], - "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(), - "different_mcp_namespace_required": False, - "exact_safe_next_action": next_safe_action, - } + if is_real_blocker: + profile = get_profile() + h = host or (REMOTES.get(remote, {}).get("host") if remote in REMOTES else None) + username = _authenticated_username(h) if h else None + next_safe_action = ( + "BLOCKED + DIAGNOSE: terminal-launcher-failure " + f"({probe_res['error_type']}): {probe_res['error_msg']}. " + "Do not attempt git/pytest finalization or unsafe fallbacks. " + "Run gitea_diagnose_terminal, emit blocked-diagnose-report, and stop." + ) + return { + "requested_task": task, + "required_operation_permission": required_permission, + "required_role_kind": required_role, + "active_profile": profile["profile_name"], + "active_identity": username, + "active_profile_allowed_operations": profile.get("allowed_operations") or [], + "allowed_in_current_session": False, + "stop_required": True, + "infra_stop": True, + "blocked": True, + "blocked_reason": "BLOCKED + DIAGNOSE", + "terminal_launcher_unhealthy": True, + "terminal_launcher_diagnostics": probe_res, + "task_role_guidance": [next_safe_action], + "matching_configured_profile": [], + "runtime_switching_supported": gitea_config.is_runtime_switching_enabled(), + "different_mcp_namespace_required": False, + "exact_safe_next_action": next_safe_action, + } record_preflight_check("capability", required_role, resolved_task=task)