From 3b2a8933c39605b39510907b39c174426101d294 Mon Sep 17 00:00:00 2001 From: 900 Date: Sat, 23 May 2026 23:45:08 +0900 Subject: [PATCH 1/5] fix(run): make fat harness opt-in --- CHANGELOG.md | 3 ++ src/ouroboros/cli/commands/run.py | 32 +++++++++++-------- src/ouroboros/mcp/tools/execution_handlers.py | 11 ++++--- tests/unit/cli/test_run_qa.py | 24 +++++++------- tests/unit/mcp/tools/test_definitions.py | 4 +-- 5 files changed, 41 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040cbca89..72e9d134b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **run/mcp**: Make fat-harness acceptance opt-in via `seed.orchestrator.execution_mode: fat_harness` for fresh CLI/MCP seed execution. Missing/blank execution mode now uses the default runner again until seed authoring and QA guidance consistently emit profile-compatible typed evidence for every AC. This mitigates layered scaffold AC failures reported in #1202. + ### Added - **providers**: GitHub Copilot CLI adapter (`CopilotCliLLMAdapter`) — first-class peer of Codex/Gemini/OpenCode adapters. Switch with `OUROBOROS_LLM_BACKEND=copilot`. Uses local `copilot -p` non-interactive mode with `GH_TOKEN`/`GITHUB_TOKEN` auth, hard tool envelope via `--available-tools`+`--allow-tool`+`--add-dir`, sandbox-class permission mapping, JSONL stream parsing, recursion guard via shared `_OUROBOROS_DEPTH` counter (max depth 5), and auth-error short-circuit on `401`/missing-token detections. Optional install: `pip install ouroboros-ai[copilot]` (the Copilot CLI itself is installed externally). - **opencode**: Subagent bridge plugin (`src/ouroboros/opencode/plugin/ouroboros-bridge.ts`) — routes MCP `ouroboros_*` tool calls with a `_subagent` parameter into OpenCode's native Task subagent panes via `session.promptAsync`. Fire-and-forget dispatch returns from the hook in ~10ms, eliminating the blocking 200s+ latency of the previous `session.prompt` approach. Installed automatically by `ouroboros setup`. See [OpenCode Subagent Bridge](docs/guides/opencode-subagent-bridge.md). diff --git a/src/ouroboros/cli/commands/run.py b/src/ouroboros/cli/commands/run.py index a3891b7ea..3c6ee0c2b 100644 --- a/src/ouroboros/cli/commands/run.py +++ b/src/ouroboros/cli/commands/run.py @@ -308,27 +308,31 @@ def _load_skip_completed_markers( def _resolve_fat_harness_mode(seed_data: dict[str, Any]) -> bool: - """Typed evidence plus verifier PASS is the only CLI acceptance path. + """Resolve the fresh-run fat-harness selector. - ``seed.orchestrator.execution_mode`` was the temporary #920 PR-4 opt-in - selector. After #978 P5, ``legacy`` is rejected instead of silently - accepting a self-report fallback selector. + Fat-harness acceptance is opt-in until the shipped authoring/QA pipeline can + reliably produce profile-compatible typed evidence for every AC. Seeds that + request ``seed.orchestrator.execution_mode: fat_harness`` keep the stricter + verifier-gated path; missing/blank selectors use the legacy runner. """ orchestrator_config = seed_data.get("orchestrator") if not isinstance(orchestrator_config, dict): - return True + return False execution_mode = orchestrator_config.get("execution_mode") if execution_mode == "legacy": print_error( "seed.orchestrator.execution_mode='legacy' was removed after #978 P5; " - "typed evidence plus verifier PASS is now required for acceptance." + "omit the selector for the default runner or set execution_mode='fat_harness' " + "to opt in to typed evidence plus verifier PASS acceptance." ) raise typer.Exit(1) - if execution_mode not in (None, "", "fat_harness"): + if execution_mode in (None, ""): + return False + if execution_mode != "fat_harness": print_error( - "seed.orchestrator.execution_mode is no longer configurable after " - f"the fat-harness default flip (got {execution_mode!r})." + "seed.orchestrator.execution_mode must be 'fat_harness' when set " + f"(got {execution_mode!r})." ) raise typer.Exit(1) @@ -342,18 +346,18 @@ def _resolve_resume_fat_harness_mode( """Resolve resume acceptance mode from persisted contract with safe migration. New sessions persist ``fat_harness_mode`` at prepare time. Historical - sessions may not have that field, so only an explicit historical - ``execution_mode: legacy`` selector resumes ungated; unknown/missing state - falls back to the conservative typed-evidence gate. + sessions may not have that field, so only an explicit ``fat_harness`` + selector resumes with verifier-gated typed-evidence enforcement; + unknown/missing state falls back to the default runner. """ persisted = progress.get("fat_harness_mode") if isinstance(persisted, bool): return persisted orchestrator_config = seed_data.get("orchestrator") - return not ( + return ( isinstance(orchestrator_config, dict) - and orchestrator_config.get("execution_mode") == "legacy" + and orchestrator_config.get("execution_mode") == "fat_harness" ) diff --git a/src/ouroboros/mcp/tools/execution_handlers.py b/src/ouroboros/mcp/tools/execution_handlers.py index 8d66f9aca..c733adbd2 100644 --- a/src/ouroboros/mcp/tools/execution_handlers.py +++ b/src/ouroboros/mcp/tools/execution_handlers.py @@ -102,15 +102,16 @@ def _validate_fresh_execution_mode( return Result.err( MCPToolError( "seed.orchestrator.execution_mode='legacy' was removed after #978 P5; " - "typed evidence plus verifier PASS is now required for acceptance.", + "omit the selector for the default runner or set execution_mode='fat_harness' " + "to opt in to typed evidence plus verifier PASS acceptance.", tool_name=tool_name, ) ) if execution_mode not in (None, "", "fat_harness"): return Result.err( MCPToolError( - "seed.orchestrator.execution_mode is no longer configurable after " - f"the fat-harness default flip (got {execution_mode!r}).", + "seed.orchestrator.execution_mode must be 'fat_harness' when set " + f"(got {execution_mode!r}).", tool_name=tool_name, ) ) @@ -498,13 +499,13 @@ async def handle( # Create checkpoint store for execution state persistence checkpoint_store = CheckpointStore() checkpoint_store.initialize() - fat_harness_mode = True + fat_harness_mode = execution_mode == "fat_harness" if is_resume: persisted_fat_harness_mode = tracker.progress.get("fat_harness_mode") if isinstance(persisted_fat_harness_mode, bool): fat_harness_mode = persisted_fat_harness_mode else: - fat_harness_mode = execution_mode != "legacy" + fat_harness_mode = execution_mode == "fat_harness" # Create orchestrator runner runner = OrchestratorRunner( diff --git a/tests/unit/cli/test_run_qa.py b/tests/unit/cli/test_run_qa.py index cc7d131cf..9da72cac3 100644 --- a/tests/unit/cli/test_run_qa.py +++ b/tests/unit/cli/test_run_qa.py @@ -216,9 +216,9 @@ def test_resolve_cli_project_dir_uses_parent_when_context_reference_is_file( ) -def test_resolve_fat_harness_mode_defaults_to_enabled() -> None: - """The #920 PR-5 default flip enables fat-harness without seed opt-in.""" - assert _resolve_fat_harness_mode(VALID_SEED_DATA) is True +def test_resolve_fat_harness_mode_defaults_to_disabled() -> None: + """Fresh runs use the default runner unless the seed opts into fat-harness.""" + assert _resolve_fat_harness_mode(VALID_SEED_DATA) is False def test_resolve_fat_harness_mode_accepts_fat_harness_execution_mode() -> None: @@ -251,12 +251,12 @@ def test_resolve_resume_fat_harness_mode_uses_persisted_contract() -> None: assert _resolve_resume_fat_harness_mode(seed_data, {"fat_harness_mode": False}) is False -def test_resolve_resume_fat_harness_mode_migrates_missing_contract_conservatively() -> None: - """Only explicit historical legacy selectors resume ungated when contract is absent.""" - legacy_seed = {**VALID_SEED_DATA, "orchestrator": {"execution_mode": "legacy"}} +def test_resolve_resume_fat_harness_mode_migrates_missing_contract_to_default_runner() -> None: + """Only explicit fat-harness selectors resume with verifier-gated acceptance.""" + fat_harness_seed = {**VALID_SEED_DATA, "orchestrator": {"execution_mode": "fat_harness"}} - assert _resolve_resume_fat_harness_mode(legacy_seed, {}) is False - assert _resolve_resume_fat_harness_mode(VALID_SEED_DATA, {}) is True + assert _resolve_resume_fat_harness_mode(fat_harness_seed, {}) is True + assert _resolve_resume_fat_harness_mode(VALID_SEED_DATA, {}) is False def test_resolve_max_decomposition_depth_defaults_to_two(monkeypatch: pytest.MonkeyPatch) -> None: @@ -444,12 +444,12 @@ async def test_run_orchestrator_passes_resolved_execution_caps_to_runner(tmp_pat assert mock_runner_cls.call_args.kwargs["max_decomposition_depth"] == 3 assert mock_runner_cls.call_args.kwargs["max_parallel_workers"] == 7 - assert mock_runner_cls.call_args.kwargs["fat_harness_mode"] is True + assert mock_runner_cls.call_args.kwargs["fat_harness_mode"] is False @pytest.mark.asyncio -async def test_run_orchestrator_passes_default_fat_harness_mode_to_runner(tmp_path: Path) -> None: - """The default #920 PR-5 path selects fat-harness without seed opt-in.""" +async def test_run_orchestrator_passes_default_runner_mode_to_runner(tmp_path: Path) -> None: + """The default path leaves fat-harness disabled unless the seed opts in.""" seed_file = tmp_path / "seed.yaml" seed_file.write_text("goal: ignored\n", encoding="utf-8") @@ -488,7 +488,7 @@ async def test_run_orchestrator_passes_default_fat_harness_mode_to_runner(tmp_pa mock_event_store_cls.return_value.initialize = AsyncMock() await _run_orchestrator(seed_file) - assert mock_runner_cls.call_args.kwargs["fat_harness_mode"] is True + assert mock_runner_cls.call_args.kwargs["fat_harness_mode"] is False @pytest.mark.asyncio diff --git a/tests/unit/mcp/tools/test_definitions.py b/tests/unit/mcp/tools/test_definitions.py index f9df3b1e8..7ad265c41 100644 --- a/tests/unit/mcp/tools/test_definitions.py +++ b/tests/unit/mcp/tools/test_definitions.py @@ -240,7 +240,7 @@ async def resume_session(self, *args: object, **kwargs: object) -> Result: assert resumed.is_ok assert legacy_resumed.is_ok assert missing_contract_resumed.is_ok - assert captured_modes == [True, True, False, True] + assert captured_modes == [False, True, False, False] async def test_handle_rejects_removed_legacy_execution_mode(self) -> None: """MCP execute_seed matches the CLI removal of the legacy selector.""" @@ -289,7 +289,7 @@ async def test_handle_rejects_unknown_execution_mode(self) -> None: ) assert result.is_err - assert "execution_mode is no longer configurable" in str(result.error) + assert "execution_mode must be 'fat_harness' when set" in str(result.error) async def test_handle_reports_execution_handler_config_error(self) -> None: """Config failures should surface with execution-handler context.""" From e228cee6e415a37561e00d95a7b28b7a096edac1 Mon Sep 17 00:00:00 2001 From: Q00 Date: Mon, 25 May 2026 23:25:53 +0900 Subject: [PATCH 2/5] fix(mcp): reject plugin fat-harness dispatch --- src/ouroboros/cli/commands/run.py | 2 +- src/ouroboros/mcp/tools/execution_handlers.py | 56 +++++++++++++++---- tests/unit/mcp/tools/test_definitions.py | 47 ++++++++++++++++ 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/ouroboros/cli/commands/run.py b/src/ouroboros/cli/commands/run.py index 3c6ee0c2b..9c47cb1da 100644 --- a/src/ouroboros/cli/commands/run.py +++ b/src/ouroboros/cli/commands/run.py @@ -502,7 +502,7 @@ async def _run_orchestrator( print_info(f"Max decomposition depth: {resolved_max_decomposition_depth}") print_info(f"Max parallel workers: {resolved_max_parallel_workers}") if resolved_fat_harness_mode: - print_info("Execution mode: fat_harness (default)") + print_info("Execution mode: fat_harness") if externally_satisfied_acs: print_info(f"Externally satisfied ACs: {len(externally_satisfied_acs)}") diff --git a/src/ouroboros/mcp/tools/execution_handlers.py b/src/ouroboros/mcp/tools/execution_handlers.py index c733adbd2..6ff12bf26 100644 --- a/src/ouroboros/mcp/tools/execution_handlers.py +++ b/src/ouroboros/mcp/tools/execution_handlers.py @@ -118,6 +118,25 @@ def _validate_fresh_execution_mode( return Result.ok(None) +def _validate_plugin_execution_mode( + execution_mode: Any, + *, + tool_name: str, +) -> Result[None, MCPToolError]: + """Reject acceptance modes plugin dispatch cannot enforce.""" + if execution_mode == "fat_harness": + return Result.err( + MCPToolError( + "seed.orchestrator.execution_mode='fat_harness' is not supported in " + "OpenCode plugin dispatch because the child task cannot enforce typed " + "evidence plus verifier PASS acceptance. Run without plugin dispatch or " + "omit the selector for the default runner.", + tool_name=tool_name, + ) + ) + return Result.ok(None) + + def _pause_metadata_from_progress(progress: dict[str, Any]) -> dict[str, Any]: """Extract pause metadata safe to expose in MCP tool results.""" metadata: dict[str, Any] = {} @@ -351,18 +370,25 @@ async def handle( if mode_result.is_err: return mode_result - # --- Subagent dispatch: gate on runtime + opencode_mode --- - payload = build_execute_subagent( - seed_content=seed_content, - session_id=session_id, - seed_path=arguments.get("seed_path"), - cwd=str(resolved_cwd), - max_iterations=max_iterations, - skip_qa=arguments.get("skip_qa", False), - model_tier=model_tier, - max_parallel_workers=max_parallel_workers, - ) if should_dispatch_via_plugin(self.agent_runtime_backend, self.opencode_mode): + if not is_resume: + plugin_mode_result = _validate_plugin_execution_mode( + execution_mode, + tool_name="ouroboros_execute_seed", + ) + if plugin_mode_result.is_err: + return plugin_mode_result + # --- Subagent dispatch: gate on runtime + opencode_mode --- + payload = build_execute_subagent( + seed_content=seed_content, + session_id=session_id, + seed_path=arguments.get("seed_path"), + cwd=str(resolved_cwd), + max_iterations=max_iterations, + skip_qa=arguments.get("skip_qa", False), + model_tier=model_tier, + max_parallel_workers=max_parallel_workers, + ) await emit_subagent_dispatched_event( self.event_store, session_id=session_id, @@ -1139,6 +1165,14 @@ async def _handle_inner( # --- Subagent dispatch: gate on runtime + opencode_mode --- # StartExecuteSeedHandler delegates to ExecuteSeedHandler internally. if should_dispatch_via_plugin(self.agent_runtime_backend, self.opencode_mode): + if not is_resume: + plugin_mode_result = _validate_plugin_execution_mode( + execution_mode, + tool_name="ouroboros_start_execute_seed", + ) + if plugin_mode_result.is_err: + return plugin_mode_result + # Initialize event store first so the audit event persists. await self._event_store.initialize() diff --git a/tests/unit/mcp/tools/test_definitions.py b/tests/unit/mcp/tools/test_definitions.py index 7ad265c41..fdbed641a 100644 --- a/tests/unit/mcp/tools/test_definitions.py +++ b/tests/unit/mcp/tools/test_definitions.py @@ -277,6 +277,29 @@ async def test_handle_plugin_rejects_removed_legacy_execution_mode( assert result.is_err assert "execution_mode='legacy' was removed" in str(result.error) + async def test_handle_plugin_rejects_fat_harness_execution_mode( + self, + memory_event_store: EventStore, + ) -> None: + """Plugin-dispatched execute_seed must not acknowledge unenforced fat-harness.""" + handler = ExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + result = await handler.handle( + { + "seed_content": VALID_SEED_YAML.replace( + "metadata:", "orchestrator:\n execution_mode: fat_harness\nmetadata:", 1 + ) + } + ) + + assert result.is_err + assert "execution_mode='fat_harness' is not supported in OpenCode plugin dispatch" in str( + result.error + ) + async def test_handle_rejects_unknown_execution_mode(self) -> None: """MCP execute_seed keeps execution_mode non-configurable like the CLI.""" handler = ExecuteSeedHandler() @@ -1658,6 +1681,30 @@ async def test_start_execute_seed_plugin_rejects_removed_legacy_execution_mode( assert result.is_err assert "execution_mode='legacy' was removed" in str(result.error) + async def test_start_execute_seed_plugin_rejects_fat_harness_execution_mode( + self, + memory_event_store: EventStore, + ) -> None: + """Plugin-dispatched start_execute_seed cannot enforce fat-harness acceptance.""" + handler = StartExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + + result = await handler.handle( + { + "seed_content": VALID_SEED_YAML.replace( + "metadata:", "orchestrator:\n execution_mode: fat_harness\nmetadata:", 1 + ) + } + ) + + assert result.is_err + assert "execution_mode='fat_harness' is not supported in OpenCode plugin dispatch" in str( + result.error + ) + def test_job_status_definition_name(self) -> None: handler = JobStatusHandler() assert handler.definition.name == "ouroboros_job_status" From 2bfc9db10d5179e2634caf39303c427b9b1635cf Mon Sep 17 00:00:00 2001 From: 900 Date: Tue, 26 May 2026 00:22:29 +0900 Subject: [PATCH 3/5] fix(mcp): reject fat-harness plugin resumes --- src/ouroboros/mcp/tools/execution_handlers.py | 62 ++++++++++++++-- tests/unit/mcp/tools/test_definitions.py | 74 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/ouroboros/mcp/tools/execution_handlers.py b/src/ouroboros/mcp/tools/execution_handlers.py index 6ff12bf26..f2c0542f0 100644 --- a/src/ouroboros/mcp/tools/execution_handlers.py +++ b/src/ouroboros/mcp/tools/execution_handlers.py @@ -137,6 +137,44 @@ def _validate_plugin_execution_mode( return Result.ok(None) +async def _validate_plugin_resume_acceptance_contract( + *, + event_store: EventStore | None, + session_id: str | None, + tool_name: str, +) -> Result[None, MCPToolError]: + """Reject plugin resumes whose persisted contract requires fat-harness.""" + if not session_id: + return Result.ok(None) + + store = event_store or EventStore() + owns_store = event_store is None + try: + await store.initialize() + tracker_result = await SessionRepository(store).reconstruct_session(session_id) + if tracker_result.is_err: + return Result.err( + MCPToolError( + f"Session resume failed: {tracker_result.error.message}", + tool_name=tool_name, + ) + ) + persisted_fat_harness_mode = tracker_result.value.progress.get("fat_harness_mode") + if persisted_fat_harness_mode is True: + return Result.err( + MCPToolError( + "OpenCode plugin dispatch cannot resume sessions created with " + "fat_harness_mode=True because the child task cannot enforce typed " + "evidence plus verifier PASS acceptance. Resume without plugin dispatch.", + tool_name=tool_name, + ) + ) + return Result.ok(None) + finally: + if owns_store: + await store.close() + + def _pause_metadata_from_progress(progress: dict[str, Any]) -> dict[str, Any]: """Extract pause metadata safe to expose in MCP tool results.""" metadata: dict[str, Any] = {} @@ -371,13 +409,19 @@ async def handle( return mode_result if should_dispatch_via_plugin(self.agent_runtime_backend, self.opencode_mode): - if not is_resume: + if is_resume: + plugin_mode_result = await _validate_plugin_resume_acceptance_contract( + event_store=self.event_store, + session_id=session_id, + tool_name="ouroboros_execute_seed", + ) + else: plugin_mode_result = _validate_plugin_execution_mode( execution_mode, tool_name="ouroboros_execute_seed", ) - if plugin_mode_result.is_err: - return plugin_mode_result + if plugin_mode_result.is_err: + return plugin_mode_result # --- Subagent dispatch: gate on runtime + opencode_mode --- payload = build_execute_subagent( seed_content=seed_content, @@ -1165,13 +1209,19 @@ async def _handle_inner( # --- Subagent dispatch: gate on runtime + opencode_mode --- # StartExecuteSeedHandler delegates to ExecuteSeedHandler internally. if should_dispatch_via_plugin(self.agent_runtime_backend, self.opencode_mode): - if not is_resume: + if is_resume: + plugin_mode_result = await _validate_plugin_resume_acceptance_contract( + event_store=self.event_store, + session_id=arguments.get("session_id"), + tool_name="ouroboros_start_execute_seed", + ) + else: plugin_mode_result = _validate_plugin_execution_mode( execution_mode, tool_name="ouroboros_start_execute_seed", ) - if plugin_mode_result.is_err: - return plugin_mode_result + if plugin_mode_result.is_err: + return plugin_mode_result # Initialize event store first so the audit event persists. await self._event_store.initialize() diff --git a/tests/unit/mcp/tools/test_definitions.py b/tests/unit/mcp/tools/test_definitions.py index fdbed641a..ad0ee8716 100644 --- a/tests/unit/mcp/tools/test_definitions.py +++ b/tests/unit/mcp/tools/test_definitions.py @@ -300,6 +300,43 @@ async def test_handle_plugin_rejects_fat_harness_execution_mode( result.error ) + async def test_handle_plugin_rejects_fat_harness_resume_contract( + self, + memory_event_store: EventStore, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Plugin-dispatched execute_seed must not resume unenforceable fat-harness sessions.""" + tracker = SessionTracker.create("exec_resume", "seed-123").with_progress( + {"fat_harness_mode": True} + ) + + class FakeSessionRepository: + def __init__(self, _event_store: EventStore) -> None: + pass + + async def reconstruct_session(self, session_id: str) -> Result: + assert session_id == "sess_resume" + return Result.ok(tracker) + + monkeypatch.setattr( + "ouroboros.mcp.tools.execution_handlers.SessionRepository", + FakeSessionRepository, + ) + handler = ExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + + result = await handler.handle( + {"seed_content": VALID_SEED_YAML, "session_id": "sess_resume"} + ) + + assert result.is_err + assert "cannot resume sessions created with fat_harness_mode=True" in str( + result.error + ) + async def test_handle_rejects_unknown_execution_mode(self) -> None: """MCP execute_seed keeps execution_mode non-configurable like the CLI.""" handler = ExecuteSeedHandler() @@ -1705,6 +1742,43 @@ async def test_start_execute_seed_plugin_rejects_fat_harness_execution_mode( result.error ) + async def test_start_execute_seed_plugin_rejects_fat_harness_resume_contract( + self, + memory_event_store: EventStore, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Plugin-dispatched start_execute_seed must not resume unenforceable fat-harness sessions.""" + tracker = SessionTracker.create("exec_resume", "seed-123").with_progress( + {"fat_harness_mode": True} + ) + + class FakeSessionRepository: + def __init__(self, _event_store: EventStore) -> None: + pass + + async def reconstruct_session(self, session_id: str) -> Result: + assert session_id == "sess_resume" + return Result.ok(tracker) + + monkeypatch.setattr( + "ouroboros.mcp.tools.execution_handlers.SessionRepository", + FakeSessionRepository, + ) + handler = StartExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + + result = await handler.handle( + {"seed_content": VALID_SEED_YAML, "session_id": "sess_resume"} + ) + + assert result.is_err + assert "cannot resume sessions created with fat_harness_mode=True" in str( + result.error + ) + def test_job_status_definition_name(self) -> None: handler = JobStatusHandler() assert handler.definition.name == "ouroboros_job_status" From abd8da65ab80bc67450a18538b14832de6530f5e Mon Sep 17 00:00:00 2001 From: 900 Date: Tue, 26 May 2026 00:33:25 +0900 Subject: [PATCH 4/5] style: format fat-harness resume tests --- tests/unit/mcp/tools/test_definitions.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/mcp/tools/test_definitions.py b/tests/unit/mcp/tools/test_definitions.py index ad0ee8716..d52b8f667 100644 --- a/tests/unit/mcp/tools/test_definitions.py +++ b/tests/unit/mcp/tools/test_definitions.py @@ -333,9 +333,7 @@ async def reconstruct_session(self, session_id: str) -> Result: ) assert result.is_err - assert "cannot resume sessions created with fat_harness_mode=True" in str( - result.error - ) + assert "cannot resume sessions created with fat_harness_mode=True" in str(result.error) async def test_handle_rejects_unknown_execution_mode(self) -> None: """MCP execute_seed keeps execution_mode non-configurable like the CLI.""" @@ -1775,9 +1773,7 @@ async def reconstruct_session(self, session_id: str) -> Result: ) assert result.is_err - assert "cannot resume sessions created with fat_harness_mode=True" in str( - result.error - ) + assert "cannot resume sessions created with fat_harness_mode=True" in str(result.error) def test_job_status_definition_name(self) -> None: handler = JobStatusHandler() From 18e9c640f5bb8db19eb94877d39ecb4d3aa8675c Mon Sep 17 00:00:00 2001 From: Q00 Date: Tue, 26 May 2026 01:18:34 +0900 Subject: [PATCH 5/5] fix(mcp): reject missing-contract plugin fat-harness resumes Reject OpenCode plugin resume requests when the persisted session contract does not contain a boolean fat_harness_mode value and the supplied seed still requests orchestrator.execution_mode='fat_harness'. This closes the historical-session gap where plugin dispatch could acknowledge a resume before the in-process fallback inferred fat-harness behavior from the seed selector. The guard now receives the parsed execution_mode for both synchronous and background execute_seed handlers, while preserving the existing rejection for explicit fat_harness_mode=True sessions. Added MCP unit coverage for missing-contract resume attempts on both handler paths. Affected files: - src/ouroboros/mcp/tools/execution_handlers.py - tests/unit/mcp/tools/test_definitions.py --- src/ouroboros/mcp/tools/execution_handlers.py | 13 ++++ tests/unit/mcp/tools/test_definitions.py | 76 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/ouroboros/mcp/tools/execution_handlers.py b/src/ouroboros/mcp/tools/execution_handlers.py index f2c0542f0..332959703 100644 --- a/src/ouroboros/mcp/tools/execution_handlers.py +++ b/src/ouroboros/mcp/tools/execution_handlers.py @@ -140,6 +140,7 @@ def _validate_plugin_execution_mode( async def _validate_plugin_resume_acceptance_contract( *, event_store: EventStore | None, + execution_mode: Any, session_id: str | None, tool_name: str, ) -> Result[None, MCPToolError]: @@ -169,6 +170,16 @@ async def _validate_plugin_resume_acceptance_contract( tool_name=tool_name, ) ) + if execution_mode == "fat_harness" and not isinstance(persisted_fat_harness_mode, bool): + return Result.err( + MCPToolError( + "OpenCode plugin dispatch cannot resume sessions whose seed requests " + "execution_mode='fat_harness' without a persisted fat_harness_mode " + "contract because the child task cannot enforce typed evidence plus " + "verifier PASS acceptance. Resume without plugin dispatch.", + tool_name=tool_name, + ) + ) return Result.ok(None) finally: if owns_store: @@ -412,6 +423,7 @@ async def handle( if is_resume: plugin_mode_result = await _validate_plugin_resume_acceptance_contract( event_store=self.event_store, + execution_mode=execution_mode, session_id=session_id, tool_name="ouroboros_execute_seed", ) @@ -1212,6 +1224,7 @@ async def _handle_inner( if is_resume: plugin_mode_result = await _validate_plugin_resume_acceptance_contract( event_store=self.event_store, + execution_mode=execution_mode, session_id=arguments.get("session_id"), tool_name="ouroboros_start_execute_seed", ) diff --git a/tests/unit/mcp/tools/test_definitions.py b/tests/unit/mcp/tools/test_definitions.py index d52b8f667..9daf552ff 100644 --- a/tests/unit/mcp/tools/test_definitions.py +++ b/tests/unit/mcp/tools/test_definitions.py @@ -335,6 +335,44 @@ async def reconstruct_session(self, session_id: str) -> Result: assert result.is_err assert "cannot resume sessions created with fat_harness_mode=True" in str(result.error) + async def test_handle_plugin_rejects_fat_harness_resume_without_contract( + self, + memory_event_store: EventStore, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Plugin-dispatched execute_seed must not infer fat-harness from seed on resume.""" + tracker = SessionTracker.create("exec_missing", "seed-123") + + class FakeSessionRepository: + def __init__(self, _event_store: EventStore) -> None: + pass + + async def reconstruct_session(self, session_id: str) -> Result: + assert session_id == "sess_missing" + return Result.ok(tracker) + + monkeypatch.setattr( + "ouroboros.mcp.tools.execution_handlers.SessionRepository", + FakeSessionRepository, + ) + handler = ExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + + result = await handler.handle( + { + "seed_content": VALID_SEED_YAML.replace( + "metadata:", "orchestrator:\n execution_mode: fat_harness\nmetadata:", 1 + ), + "session_id": "sess_missing", + } + ) + + assert result.is_err + assert "without a persisted fat_harness_mode contract" in str(result.error) + async def test_handle_rejects_unknown_execution_mode(self) -> None: """MCP execute_seed keeps execution_mode non-configurable like the CLI.""" handler = ExecuteSeedHandler() @@ -1775,6 +1813,44 @@ async def reconstruct_session(self, session_id: str) -> Result: assert result.is_err assert "cannot resume sessions created with fat_harness_mode=True" in str(result.error) + async def test_start_execute_seed_plugin_rejects_fat_harness_resume_without_contract( + self, + memory_event_store: EventStore, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Plugin-dispatched start_execute_seed must not infer fat-harness on resume.""" + tracker = SessionTracker.create("exec_missing", "seed-123") + + class FakeSessionRepository: + def __init__(self, _event_store: EventStore) -> None: + pass + + async def reconstruct_session(self, session_id: str) -> Result: + assert session_id == "sess_missing" + return Result.ok(tracker) + + monkeypatch.setattr( + "ouroboros.mcp.tools.execution_handlers.SessionRepository", + FakeSessionRepository, + ) + handler = StartExecuteSeedHandler( + event_store=memory_event_store, + agent_runtime_backend="opencode", + opencode_mode="plugin", + ) + + result = await handler.handle( + { + "seed_content": VALID_SEED_YAML.replace( + "metadata:", "orchestrator:\n execution_mode: fat_harness\nmetadata:", 1 + ), + "session_id": "sess_missing", + } + ) + + assert result.is_err + assert "without a persisted fat_harness_mode contract" in str(result.error) + def test_job_status_definition_name(self) -> None: handler = JobStatusHandler() assert handler.definition.name == "ouroboros_job_status"