From 4196843473ed4d5b31fa1dd3c19e3c18e49eebb7 Mon Sep 17 00:00:00 2001 From: cafitac Date: Fri, 1 May 2026 22:06:45 +0900 Subject: [PATCH] refactor: clarify orchestrator runtime boundary --- .../hermes-executor-readiness/README.md | 20 ++++++----- .../orchestrator-adapter-architecture.md | 21 ++++++++--- .dev/refactor/roadmap.md | 2 +- CHANGELOG.md | 1 + hermit_agent/orchestrators/__init__.py | 4 +++ hermit_agent/orchestrators/claude.py | 18 ---------- hermit_agent/orchestrators/codex.py | 18 ---------- hermit_agent/orchestrators/contracts.py | 23 ++++++++++-- hermit_agent/orchestrators/hermes.py | 18 ---------- tests/test_claude_orchestrator_adapter.py | 21 +++-------- tests/test_codex_orchestrator_adapter.py | 21 +++-------- tests/test_hermes_orchestrator_adapter.py | 6 ++-- tests/test_orchestrator_contracts.py | 36 +++++++++++++++++-- 13 files changed, 99 insertions(+), 110 deletions(-) diff --git a/.dev/refactor/hermes-executor-readiness/README.md b/.dev/refactor/hermes-executor-readiness/README.md index efbfcdf..a84a538 100644 --- a/.dev/refactor/hermes-executor-readiness/README.md +++ b/.dev/refactor/hermes-executor-readiness/README.md @@ -65,21 +65,23 @@ Keep the existing working runtime paths while extracting a neutral adapter bound Current implemented adapter pieces: - `hermit_agent/orchestrators/contracts.py` - - neutral DTO/protocol definitions + - neutral DTO definitions + - `OrchestratorSetupAdapter` for install/health surfaces + - `OrchestratorRuntimeAdapter` reserved for future non-MCP lifecycle owners - `hermit_agent/orchestrators/hermes.py` - thin Hermes setup/doctor/live-smoke wrapper - - runtime lifecycle methods intentionally raise `NotImplementedError` +- `hermit_agent/orchestrators/claude.py` + - thin Claude setup/health wrapper +- `hermit_agent/orchestrators/codex.py` + - thin Codex setup/health wrapper - `hermit_agent/orchestrators/prompts.py` - runtime `InteractivePrompt` ↔ adapter `InteractivePrompt` mapping - `PromptReply` helper -Important current gap: -- `HermesMcpAdapter.submit_task()` is not implemented. -- `HermesMcpAdapter.emit_event()` is not implemented. -- `HermesMcpAdapter.wait_for_reply()` is not implemented. -- `HermesMcpAdapter.cancel()` is not implemented. - -This is acceptable only if the canonical Hermes runtime remains the existing MCP server path and the adapter is documented as install/doctor-only. If the target architecture requires all orchestrators behind `OrchestratorAdapter`, later PRs must wire those lifecycle methods to the MCP/gateway task proxy. +Current runtime decision: +- Hermit's MCP server remains the canonical runtime task boundary. +- Claude Code, Codex, and Hermes Agent invoke Hermit through MCP tools (`run_task`, `reply_task`, `check_task`, `cancel_task`). +- Adapter wrappers currently describe setup/health/smoke only; they do not imply adapter-owned task submission or event delivery. ## PR sequence diff --git a/.dev/refactor/orchestrator-adapter-architecture.md b/.dev/refactor/orchestrator-adapter-architecture.md index 91d48ba..d59c756 100644 --- a/.dev/refactor/orchestrator-adapter-architecture.md +++ b/.dev/refactor/orchestrator-adapter-architecture.md @@ -4,9 +4,16 @@ Hermit currently supports Claude Code and Codex, but several integration details are spread across MCP server code, channel notification code, codex-channels code, install flow, and tests. Adding Hermes Agent should not mean adding a third set of special cases in the core loop. -## Target model +## Runtime boundary decision -Hermit core should expose an executor task protocol. Orchestrators should be adapters around that protocol. +Current decision: Hermit's MCP server is the canonical runtime task boundary for Claude Code, Codex, and Hermes Agent. Adapters currently own setup, health, and smoke-check DTO surfaces; they do not own task submission, event delivery, prompt replies, or cancellation. + +This keeps the working runtime path honest: +- orchestrators discover Hermit through setup adapters +- orchestrators invoke Hermit through MCP tools (`run_task`, `reply_task`, `check_task`, `cancel_task`) +- prompt/event DTO helpers exist for future extraction and documentation, but do not replace MCP delivery yet + +Future model, if a concrete product bug requires adapter-owned runtime delivery: ``` Orchestrator @@ -102,17 +109,21 @@ Implemented scaffold: `hermit_agent/orchestrators/contracts.py` now defines the Use this as the design target for the scaffold and later adapter extraction: ```python -class OrchestratorAdapter: +class OrchestratorSetupAdapter: name: str def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult: ... def health(self, *, cwd: str) -> AdapterHealth: ... + +class OrchestratorRuntimeAdapter(OrchestratorSetupAdapter): def submit_task(self, request: TaskRequest) -> TaskHandle: ... def emit_event(self, task_id: str, event: TaskEvent) -> None: ... def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: ... def cancel(self, task_id: str) -> None: ... ``` +`OrchestratorAdapter` remains an alias for the future runtime protocol for compatibility, but current Claude/Codex/Hermes wrappers intentionally satisfy only `OrchestratorSetupAdapter`. + DTOs should stay orchestrator-neutral: - `TaskRequest` - `TaskHandle` @@ -123,7 +134,7 @@ DTOs should stay orchestrator-neutral: - `AdapterInstallResult` Current test anchor: -- `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, and the structural lifecycle shape of `OrchestratorAdapter`. +- `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, the setup-only protocol shape, and the separate future runtime protocol shape. - `tests/test_hermes_orchestrator_adapter.py` verifies `HermesMcpAdapter` maps existing Hermes install, live-smoke, and doctor helpers into `AdapterInstallResult` / `AdapterHealth` without mutating behavior. - `tests/test_claude_orchestrator_adapter.py` verifies `ClaudeCodeMcpAdapter` maps existing Claude MCP print/fix/registration-health helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior. - `tests/test_codex_orchestrator_adapter.py` verifies `CodexAdapter` maps existing codex-channels, Codex marketplace, Codex MCP, and legacy reply-hook helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior. @@ -131,8 +142,8 @@ Current test anchor: - `tests/test_orchestrator_event_mapping.py` verifies current Gateway SSE events, channel actions, and task status payloads can map into neutral `TaskEvent` DTOs without rewiring delivery behavior. Next extraction candidates: -- start equivalent Claude/Codex install and health wrappers after the Hermes wrapper shape proves stable - keep MCP server as the canonical runtime boundary unless a later smoke proves an adapter-owned runtime path is safer +- only implement `OrchestratorRuntimeAdapter` methods when a specific orchestrator needs a non-MCP lifecycle path ## Non-goals diff --git a/.dev/refactor/roadmap.md b/.dev/refactor/roadmap.md index 8c225a4..5ab00e6 100644 --- a/.dev/refactor/roadmap.md +++ b/.dev/refactor/roadmap.md @@ -80,7 +80,7 @@ Acceptance: ### R2.1 Define orchestrator-neutral contracts -Status: first scaffold implemented in `hermit_agent/orchestrators/contracts.py`; behavior remains on the existing Claude/Codex/Hermes paths until R2.2 extraction slices. +Status: `hermit_agent/orchestrators/contracts.py` now separates `OrchestratorSetupAdapter` from the future `OrchestratorRuntimeAdapter`. `OrchestratorAdapter` remains a compatibility alias for the runtime protocol, while current Claude/Codex/Hermes wrappers satisfy only the setup/health surface. Behavior remains on the existing MCP server path. Target surfaces: - task submission diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b9349..ecf4f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ - Added adapter prompt mapping helpers that round-trip the current runtime `InteractivePrompt` shape through the neutral `InteractivePrompt` / `PromptReply` DTOs without changing interactive delivery behavior. - Added pure task event mapping helpers that convert existing Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs without changing MCP/channel delivery behavior. - Added thin Claude Code and Codex adapter wrappers that map existing setup and health helpers onto the shared adapter DTOs without changing the current MCP/channel runtime delivery paths. +- Split the adapter contract into `OrchestratorSetupAdapter` and future `OrchestratorRuntimeAdapter` protocols so current Claude/Codex/Hermes wrappers no longer imply they own task submission or prompt/event delivery while Hermit's MCP server remains the canonical runtime boundary. - Started tracking the long-term refactor roadmap under `.dev/refactor/**` while leaving other `.dev/*` scratch files ignored. - Removed warning-producing test mocks for setup coroutines so the suite no longer emits unawaited `init_db` / `create_api_key` runtime warnings. - Aligned contributor and harness docs on `.venv/bin/python -m pytest tests/` and added a repo hygiene regression so stale `.venv/bin/pytest` shebangs are not reintroduced as the documented path. diff --git a/hermit_agent/orchestrators/__init__.py b/hermit_agent/orchestrators/__init__.py index 2a000cb..daf24e4 100644 --- a/hermit_agent/orchestrators/__init__.py +++ b/hermit_agent/orchestrators/__init__.py @@ -9,6 +9,8 @@ AdapterInstallStatus, InteractivePrompt, OrchestratorAdapter, + OrchestratorRuntimeAdapter, + OrchestratorSetupAdapter, PromptReply, TaskEvent, TaskEventKind, @@ -37,6 +39,8 @@ "HermesMcpAdapter", "InteractivePrompt", "OrchestratorAdapter", + "OrchestratorRuntimeAdapter", + "OrchestratorSetupAdapter", "PromptReply", "TaskEvent", "TaskEventKind", diff --git a/hermit_agent/orchestrators/claude.py b/hermit_agent/orchestrators/claude.py index 2a3b13e..4782127 100644 --- a/hermit_agent/orchestrators/claude.py +++ b/hermit_agent/orchestrators/claude.py @@ -11,11 +11,6 @@ AdapterHealthStatus, AdapterInstallResult, AdapterInstallStatus, - InteractivePrompt, - PromptReply, - TaskEvent, - TaskHandle, - TaskRequest, ) from ..install_flow import inspect_claude_mcp_registration, register_claude_mcp, resolve_hermit_mcp_stdio_entry @@ -58,19 +53,6 @@ def health(self, *, cwd: str) -> AdapterHealth: status = inspect_claude_mcp_registration(entry=resolve_hermit_mcp_stdio_entry(cwd=cwd)) return _health_from_registration_status(status) - def submit_task(self, request: TaskRequest) -> TaskHandle: - raise NotImplementedError("Claude Code task submission still uses the MCP server path directly") - - def emit_event(self, task_id: str, event: TaskEvent) -> None: - raise NotImplementedError("Claude Code event delivery still uses the MCP server path directly") - - def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: - raise NotImplementedError("Claude Code reply delivery still uses the MCP server path directly") - - def cancel(self, task_id: str) -> None: - raise NotImplementedError("Claude Code cancellation still uses the MCP server path directly") - - def _install_result_from_status(status: str, *, path: object, backup: object | None) -> AdapterInstallResult: if status == "registered": install_status = AdapterInstallStatus.REGISTERED diff --git a/hermit_agent/orchestrators/codex.py b/hermit_agent/orchestrators/codex.py index a98ec00..c08e2e4 100644 --- a/hermit_agent/orchestrators/codex.py +++ b/hermit_agent/orchestrators/codex.py @@ -11,11 +11,6 @@ AdapterHealthStatus, AdapterInstallResult, AdapterInstallStatus, - InteractivePrompt, - PromptReply, - TaskEvent, - TaskHandle, - TaskRequest, ) from ..install_flow import ( ensure_codex_channels_ready, @@ -103,19 +98,6 @@ def health(self, *, cwd: str) -> AdapterHealth: message="codex-channels runtime missing — run `hermit install` or use fix=True adapter setup", ) - def submit_task(self, request: TaskRequest) -> TaskHandle: - raise NotImplementedError("Codex task submission still uses the existing Codex channel/MCP runtime paths") - - def emit_event(self, task_id: str, event: TaskEvent) -> None: - raise NotImplementedError("Codex event delivery still uses the existing Codex channel/MCP runtime paths") - - def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: - raise NotImplementedError("Codex reply delivery still uses the existing Codex channel/MCP runtime paths") - - def cancel(self, task_id: str) -> None: - raise NotImplementedError("Codex cancellation still uses the existing Codex channel/MCP runtime paths") - - def _codex_statuses_changed(runtime_status: str, marketplace_status: str, mcp_status: str, hook_status: str) -> bool: return any( status in {"installed", "registered", "removed"} diff --git a/hermit_agent/orchestrators/contracts.py b/hermit_agent/orchestrators/contracts.py index 875051a..1ff56c2 100644 --- a/hermit_agent/orchestrators/contracts.py +++ b/hermit_agent/orchestrators/contracts.py @@ -120,8 +120,13 @@ class AdapterInstallResult: @runtime_checkable -class OrchestratorAdapter(Protocol): - """Minimal lifecycle contract for higher-level Hermit orchestrators.""" +class OrchestratorSetupAdapter(Protocol): + """Setup/health contract for optional higher-level Hermit orchestrators. + + The MCP server is the canonical runtime task boundary today. Setup adapters + expose registration and diagnostics without implying that they own task + submission, event delivery, prompt replies, or cancellation. + """ name: str @@ -131,6 +136,17 @@ def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstal def health(self, *, cwd: str) -> AdapterHealth: """Return read-only adapter health for diagnostics/doctor output.""" + +@runtime_checkable +class OrchestratorRuntimeAdapter(OrchestratorSetupAdapter, Protocol): + """Future adapter-owned task lifecycle contract. + + Current Claude Code, Codex, and Hermes integrations use Hermit's MCP server + tools (`run_task`, `reply_task`, `check_task`, `cancel_task`) as the stable + runtime surface. Implement this protocol only for a future orchestrator that + truly owns those lifecycle operations outside the MCP tool boundary. + """ + def submit_task(self, request: TaskRequest) -> TaskHandle: """Submit a task to Hermit and return a stable task handle.""" @@ -142,3 +158,6 @@ def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply def cancel(self, task_id: str) -> None: """Cancel an in-flight task or clear adapter-side task state.""" + + +OrchestratorAdapter = OrchestratorRuntimeAdapter diff --git a/hermit_agent/orchestrators/hermes.py b/hermit_agent/orchestrators/hermes.py index 03c2020..64be369 100644 --- a/hermit_agent/orchestrators/hermes.py +++ b/hermit_agent/orchestrators/hermes.py @@ -12,11 +12,6 @@ AdapterHealthStatus, AdapterInstallResult, AdapterInstallStatus, - InteractivePrompt, - PromptReply, - TaskEvent, - TaskHandle, - TaskRequest, ) from ..doctor import DiagCheck, DiagStatus, _check_hermes_mcp as check_hermes_mcp from ..install_flow import ( @@ -58,19 +53,6 @@ def live_smoke(self, *, cwd: str) -> AdapterHealth: health_status = AdapterHealthStatus.FAIL return AdapterHealth(name=self.name, status=health_status, message=status) - def submit_task(self, request: TaskRequest) -> TaskHandle: - raise NotImplementedError("Hermes task submission still uses the MCP server path directly") - - def emit_event(self, task_id: str, event: TaskEvent) -> None: - raise NotImplementedError("Hermes event delivery still uses the MCP server path directly") - - def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: - raise NotImplementedError("Hermes reply delivery still uses the MCP server path directly") - - def cancel(self, task_id: str) -> None: - raise NotImplementedError("Hermes cancellation still uses the MCP server path directly") - - def _install_result_from_status(status: str) -> AdapterInstallResult: if status == "registered": install_status = AdapterInstallStatus.REGISTERED diff --git a/tests/test_claude_orchestrator_adapter.py b/tests/test_claude_orchestrator_adapter.py index 861b87c..f87dfcd 100644 --- a/tests/test_claude_orchestrator_adapter.py +++ b/tests/test_claude_orchestrator_adapter.py @@ -1,15 +1,11 @@ from __future__ import annotations -import pytest - from hermit_agent.orchestrators import ( AdapterHealthStatus, AdapterInstallStatus, ClaudeCodeMcpAdapter, - InteractivePrompt, - TaskEvent, - TaskEventKind, - TaskRequest, + OrchestratorRuntimeAdapter, + OrchestratorSetupAdapter, ) @@ -24,6 +20,8 @@ def fail_register(**kwargs): result = ClaudeCodeMcpAdapter().install_or_print_instructions(cwd="/repo", fix=False) + assert isinstance(ClaudeCodeMcpAdapter(), OrchestratorSetupAdapter) + assert not isinstance(ClaudeCodeMcpAdapter(), OrchestratorRuntimeAdapter) assert result.name == "claude-code" assert result.status == AdapterInstallStatus.PRINTED assert result.changed is False @@ -71,14 +69,3 @@ def test_claude_adapter_health_maps_registration_status(monkeypatch): assert "invalid" in invalid.message -def test_claude_adapter_lifecycle_methods_are_explicitly_unsupported(): - adapter = ClaudeCodeMcpAdapter() - - with pytest.raises(NotImplementedError, match="MCP server path"): - adapter.submit_task(TaskRequest(task="do it", cwd="/repo")) - with pytest.raises(NotImplementedError, match="MCP server path"): - adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING)) - with pytest.raises(NotImplementedError, match="MCP server path"): - adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?")) - with pytest.raises(NotImplementedError, match="MCP server path"): - adapter.cancel("task-1") diff --git a/tests/test_codex_orchestrator_adapter.py b/tests/test_codex_orchestrator_adapter.py index 0ed5201..3820cba 100644 --- a/tests/test_codex_orchestrator_adapter.py +++ b/tests/test_codex_orchestrator_adapter.py @@ -1,15 +1,11 @@ from __future__ import annotations -import pytest - from hermit_agent.orchestrators import ( AdapterHealthStatus, AdapterInstallStatus, CodexAdapter, - InteractivePrompt, - TaskEvent, - TaskEventKind, - TaskRequest, + OrchestratorRuntimeAdapter, + OrchestratorSetupAdapter, ) @@ -24,6 +20,8 @@ def fail_install(**kwargs): result = CodexAdapter().install_or_print_instructions(cwd="/repo", fix=False) + assert isinstance(CodexAdapter(), OrchestratorSetupAdapter) + assert not isinstance(CodexAdapter(), OrchestratorRuntimeAdapter) assert result.name == "codex" assert result.status == AdapterInstallStatus.SKIPPED assert result.changed is False @@ -103,14 +101,3 @@ def test_codex_adapter_health_uses_runtime_version(monkeypatch): assert "missing" in missing.message -def test_codex_adapter_lifecycle_methods_are_explicitly_unsupported(): - adapter = CodexAdapter() - - with pytest.raises(NotImplementedError, match="existing Codex channel"): - adapter.submit_task(TaskRequest(task="do it", cwd="/repo")) - with pytest.raises(NotImplementedError, match="existing Codex channel"): - adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING)) - with pytest.raises(NotImplementedError, match="existing Codex channel"): - adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?")) - with pytest.raises(NotImplementedError, match="existing Codex channel"): - adapter.cancel("task-1") diff --git a/tests/test_hermes_orchestrator_adapter.py b/tests/test_hermes_orchestrator_adapter.py index d597c5b..9dadf0a 100644 --- a/tests/test_hermes_orchestrator_adapter.py +++ b/tests/test_hermes_orchestrator_adapter.py @@ -1,7 +1,7 @@ from __future__ import annotations from hermit_agent.doctor import DiagCheck, DiagStatus -from hermit_agent.orchestrators import AdapterHealthStatus, AdapterInstallStatus, OrchestratorAdapter +from hermit_agent.orchestrators import AdapterHealthStatus, AdapterInstallStatus, OrchestratorRuntimeAdapter, OrchestratorSetupAdapter from hermit_agent.orchestrators.hermes import HermesMcpAdapter @@ -18,9 +18,11 @@ def fake_snippet(*, cwd: str) -> str: lambda **kwargs: (_ for _ in ()).throw(AssertionError("print-only path must not mutate Hermes config")), ) - adapter: OrchestratorAdapter = HermesMcpAdapter() + adapter: OrchestratorSetupAdapter = HermesMcpAdapter() result = adapter.install_or_print_instructions(cwd="/repo", fix=False) + assert isinstance(adapter, OrchestratorSetupAdapter) + assert not isinstance(adapter, OrchestratorRuntimeAdapter) assert result.name == "hermes" assert result.status == AdapterInstallStatus.PRINTED assert result.changed is False diff --git a/tests/test_orchestrator_contracts.py b/tests/test_orchestrator_contracts.py index b901ee1..1bc93cb 100644 --- a/tests/test_orchestrator_contracts.py +++ b/tests/test_orchestrator_contracts.py @@ -9,6 +9,8 @@ AdapterHealthStatus, AdapterInstallResult, AdapterInstallStatus, + OrchestratorRuntimeAdapter, + OrchestratorSetupAdapter, InteractivePrompt, OrchestratorAdapter, PromptReply, @@ -82,8 +84,34 @@ def test_contract_dtos_have_stable_status_and_event_values(): assert install.changed is False -def test_adapter_protocol_supports_core_lifecycle_shape(): - class RecordingAdapter: +def test_setup_adapter_protocol_is_separate_from_runtime_lifecycle_shape(): + class RecordingSetupAdapter: + name = "recording" + + def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult: + return AdapterInstallResult( + name=self.name, + status=AdapterInstallStatus.REGISTERED if fix else AdapterInstallStatus.PRINTED, + message=f"cwd={cwd}", + changed=fix, + ) + + def health(self, *, cwd: str) -> AdapterHealth: + return AdapterHealth(name=self.name, status=AdapterHealthStatus.PASS, message=cwd) + + adapter: OrchestratorSetupAdapter = RecordingSetupAdapter() + + install = adapter.install_or_print_instructions(cwd="/repo", fix=True) + health = adapter.health(cwd="/repo") + + assert install.status == AdapterInstallStatus.REGISTERED + assert health.status == AdapterHealthStatus.PASS + assert isinstance(adapter, OrchestratorSetupAdapter) + assert not isinstance(adapter, OrchestratorRuntimeAdapter) + + +def test_runtime_adapter_protocol_extends_setup_with_lifecycle_shape(): + class RecordingRuntimeAdapter: name = "recording" def __init__(self) -> None: @@ -114,7 +142,7 @@ def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply def cancel(self, task_id: str) -> None: self.cancelled.append(task_id) - adapter: OrchestratorAdapter = RecordingAdapter() + adapter: OrchestratorRuntimeAdapter = RecordingRuntimeAdapter() request = TaskRequest(task="Run tests", cwd="/repo") install = adapter.install_or_print_instructions(cwd="/repo", fix=True) @@ -130,5 +158,7 @@ def cancel(self, task_id: str) -> None: assert health.status == AdapterHealthStatus.PASS assert handle.url == "/repo" assert reply == PromptReply(task_id="task-1", answer="yes", approved=True) + assert isinstance(adapter, OrchestratorSetupAdapter) + assert isinstance(adapter, OrchestratorRuntimeAdapter) assert isinstance(adapter, OrchestratorAdapter) assert adapter.cancelled == ["task-1"]