diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index bc35c9a54..fac35ea91 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -192,7 +192,11 @@ data: # Hermes project coordinator Use the native Project and Kanban surfaces. Cassandra uses project and board - slug `cassandra` with workspace `/opt/data/workspace/projects/cassandra`. + slug `cassandra`. Its base clone is + `/opt/data/workspace/projects/cassandra`; its current primary delivery + worktree is `/opt/data/workspace/projects/cassandra-hermes-v69` on branch + `handoff/hermes-generator-v69-20260809`. Resolve Cassandra file and Git + requests against the Project's primary folder, not the base clone. Put objectives needing decomposition in Triage. Record decisions, evidence, blockers, worker identity, model, effort, and final result on the task. diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index a6cd3877e..067686af3 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -321,6 +321,7 @@ spec: - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: PYTHONPATH, value: /opt/hermes} - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json} + - {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} securityContext: allowPrivilegeEscalation: false @@ -361,6 +362,7 @@ spec: - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: PYTHONPATH, value: /opt/hermes} - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json} + - {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} securityContext: allowPrivilegeEscalation: false @@ -683,6 +685,7 @@ spec: - {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude} - {name: PYTHONPATH, value: /opt/hermes} - {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json} + - {name: HERMES_CASSANDRA_ACTIVE_WORKTREE, value: /opt/data/workspace/projects/cassandra-hermes-v69} - {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin} securityContext: allowPrivilegeEscalation: false diff --git a/services/hermes/scripts/hermes_coordinator.py b/services/hermes/scripts/hermes_coordinator.py index 433d5e3ec..0619eb029 100644 --- a/services/hermes/scripts/hermes_coordinator.py +++ b/services/hermes/scripts/hermes_coordinator.py @@ -23,27 +23,54 @@ from hermes_model_routing import ( ) -CASSANDRA_PATH = Path("/opt/data/workspace/projects/cassandra") +CASSANDRA_BASE_PATH = Path("/opt/data/workspace/projects/cassandra") CASSANDRA_REMOTE = "https://scm.bstein.dev/bstein/cassandra.git" +def cassandra_workspace() -> Path: + """Return the configured active worktree, falling back to the base clone.""" + configured = os.environ.get("HERMES_CASSANDRA_ACTIVE_WORKTREE", "").strip() + if not configured: + return CASSANDRA_BASE_PATH + candidate = Path(configured).expanduser() + if candidate.is_dir() and (candidate / ".git").exists(): + return candidate + return CASSANDRA_BASE_PATH + + +def _migrate_open_cassandra_tasks(kb: Any, workspace: Path) -> None: + """Point unfinished, idle Cassandra cards at the active worktree.""" + if workspace == CASSANDRA_BASE_PATH: + return + with kb.connect_closing(board="cassandra") as connection: + for task in kb.list_tasks(connection, include_archived=False): + if ( + task.workspace_kind == "dir" + and task.workspace_path == str(CASSANDRA_BASE_PATH) + and task.status not in {"done", "archived", "running"} + ): + kb.set_workspace_path(connection, task.id, workspace) + + def bootstrap_cassandra(root: Path) -> None: """Create the initial isolated board and project without resetting user state.""" os.environ["HERMES_HOME"] = str(root) from hermes_cli import kanban_db as kb from hermes_cli import projects_db as pdb + workspace = cassandra_workspace() first_create = not kb.board_exists("cassandra") kb.create_board( "cassandra", name="Cassandra", description="Objectives, implementation tasks, reviews, and evidence for Cassandra.", - default_workdir=str(CASSANDRA_PATH), + default_workdir=str(workspace), ) if first_create: kb.set_current_board("cassandra") - CASSANDRA_PATH.parent.mkdir(parents=True, exist_ok=True) + CASSANDRA_BASE_PATH.parent.mkdir(parents=True, exist_ok=True) + project_folders = list(dict.fromkeys((str(CASSANDRA_BASE_PATH), str(workspace)))) with pdb.connect_closing() as connection: project = pdb.get_project(connection, "cassandra") if project is None: @@ -51,8 +78,8 @@ def bootstrap_cassandra(root: Path) -> None: connection, name="Cassandra", slug="cassandra", - folders=[str(CASSANDRA_PATH)], - primary_path=str(CASSANDRA_PATH), + folders=project_folders, + primary_path=str(workspace), description="Cassandra project objectives and coordinated delivery.", board_slug="cassandra", ) @@ -66,7 +93,20 @@ def bootstrap_cassandra(root: Path) -> None: description="Cassandra project objectives and coordinated delivery.", board_slug="cassandra", ) - pdb.add_folder(connection, project.id, str(CASSANDRA_PATH), is_primary=True) + pdb.add_folder( + connection, + project.id, + str(CASSANDRA_BASE_PATH), + label="Base clone", + ) + pdb.add_folder( + connection, + project.id, + str(workspace), + label="Active delivery worktree", + is_primary=True, + ) + _migrate_open_cassandra_tasks(kb, workspace) def sync_cassandra_repo(env_values: dict[str, str]) -> str: @@ -74,25 +114,31 @@ def sync_cassandra_repo(env_values: dict[str, str]) -> str: token = env_values.get("GITEA_TOKEN", "").strip() if shutil.which("git") is None: return "git-unavailable" - if (CASSANDRA_PATH / ".git").is_dir(): + if (CASSANDRA_BASE_PATH / ".git").is_dir(): if not token: return "ready; fetch skipped until Gitea token is configured" command = [ "git", "-C", - str(CASSANDRA_PATH), + str(CASSANDRA_BASE_PATH), "fetch", "--quiet", "--prune", "origin", ] else: - CASSANDRA_PATH.mkdir(parents=True, exist_ok=True) - if any(CASSANDRA_PATH.iterdir()): + CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True) + if any(CASSANDRA_BASE_PATH.iterdir()): return "unmanaged-nonempty-directory" if not token: return "awaiting-gitea-token" - command = ["git", "clone", "--quiet", CASSANDRA_REMOTE, str(CASSANDRA_PATH)] + command = [ + "git", + "clone", + "--quiet", + CASSANDRA_REMOTE, + str(CASSANDRA_BASE_PATH), + ] child_env = os.environ.copy() child_env.update(env_values) try: @@ -133,7 +179,7 @@ def refresh_once(root: Path) -> dict[str, Any]: "projects": { "cassandra": { "board": "cassandra", - "workspace": str(CASSANDRA_PATH), + "workspace": str(cassandra_workspace()), "repository": CASSANDRA_REMOTE, "state": repo_state, } diff --git a/services/hermes/switchyard-configmap.yaml b/services/hermes/switchyard-configmap.yaml index 864c04533..955e457a9 100644 --- a/services/hermes/switchyard-configmap.yaml +++ b/services/hermes/switchyard-configmap.yaml @@ -219,7 +219,16 @@ data: the fastest target at the safety floor, never below it. An explicit available provider or model wins at the safety floor. - 3. Use local Qwen only at low or medium for low-risk conversation, + 3. Route image creation and editing before general provider preference. + When image-generation/edit tools are available and the user asks to create, + transform, restore, colorize, or continue editing an image: select + local_qwen_medium when local generation is requested; select + codex_terra_medium when OpenAI or hosted generation is requested; otherwise + choose either of those two medium targets. Do not select a Claude target for + an image-tool boundary: Anthropic supplies the conversation model, not an + image backend, and Claude capacity must not block image execution. + + 4. Use local Qwen only at low or medium for low-risk conversation, formatting, lookup, and continuity. Prefer Codex for implementation, debugging, tests, commands, and repository work. Prefer Claude for architecture, ambiguity, synthesis, risk analysis, adversarial analysis, @@ -227,7 +236,7 @@ data: select a provider stated to be unavailable, failed, exhausted, rate-limited, or out of capacity; use the other provider at the same floor. - 4. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, + 5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, medium=claude_sonnet_medium, high=claude_sonnet_high, xhigh=claude_opus_xhigh; local low=local_qwen_low and local @@ -281,7 +290,16 @@ data: the fastest target at the safety floor, never below it. An explicit available provider or model wins at the safety floor. - 3. Use local Qwen only at low or medium for low-risk conversation, + 3. Route image creation and editing before general provider preference. + When image-generation/edit tools are available and the user asks to create, + transform, restore, colorize, or continue editing an image: select + local_qwen_medium when local generation is requested; select + codex_terra_medium when OpenAI or hosted generation is requested; otherwise + choose either of those two medium targets. Do not select a Claude target for + an image-tool boundary: Anthropic supplies the conversation model, not an + image backend, and Claude capacity must not block image execution. + + 4. Use local Qwen only at low or medium for low-risk conversation, formatting, lookup, and continuity. Prefer Codex for implementation, debugging, tests, commands, and repository work. Prefer Claude for architecture, ambiguity, synthesis, risk analysis, adversarial analysis, @@ -289,7 +307,7 @@ data: select a provider stated to be unavailable, failed, exhausted, rate-limited, or out of capacity; use the other provider at the same floor. - 4. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, + 5. Map exactly: Codex low=codex_luna_low, medium=codex_terra_medium, high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low, medium=claude_sonnet_medium, high=claude_sonnet_high, xhigh=claude_opus_xhigh; local low=local_qwen_low and local diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 3b8d629f1..b695f901c 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -1071,6 +1071,10 @@ def test_local_flux_runtime_and_gpu_handoff_are_flux_managed(): assert any( target.startswith("local_") for target in routes[route_name]["targets"] ) + for route_name in ("auto_fast", "auto_balanced"): + prompt = routes[route_name]["prompt"] + assert "Route image creation and editing before general provider" in prompt + assert "Do not select a Claude target for" in prompt assert "max_output_tokens" not in routes["worker_auto_maximum"] model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][ "model_gate.py" diff --git a/testing/tests/test_hermes_coordinator.py b/testing/tests/test_hermes_coordinator.py index d9a0d97a9..6dd5468e9 100644 --- a/testing/tests/test_hermes_coordinator.py +++ b/testing/tests/test_hermes_coordinator.py @@ -303,3 +303,58 @@ def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch): assert status_path.is_file() assert status["projects"]["cassandra"]["state"] == "ready" assert "do-not-report" not in status_path.read_text(encoding="utf-8") + + +def test_cassandra_workspace_uses_valid_configured_worktree( + tmp_path: Path, monkeypatch +): + """The coordinator selects an explicit Git worktree but rejects stale paths.""" + active = tmp_path / "cassandra-v69" + active.mkdir() + (active / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8") + monkeypatch.setenv("HERMES_CASSANDRA_ACTIVE_WORKTREE", str(active)) + + assert coordinator.cassandra_workspace() == active + + (active / ".git").unlink() + assert coordinator.cassandra_workspace() == coordinator.CASSANDRA_BASE_PATH + + +def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks(): + """A project switch moves queued work without relocating active evidence.""" + class Task: + def __init__(self, task_id: str, status: str): + self.id = task_id + self.status = status + self.workspace_kind = "dir" + self.workspace_path = str(coordinator.CASSANDRA_BASE_PATH) + + class Closing: + def __enter__(self): + return object() + + def __exit__(self, *_args): + return None + + class FakeKanban: + tasks = [Task("queued", "todo"), Task("active", "running"), Task("done", "done")] + moved: list[tuple[str, str]] = [] + + @staticmethod + def connect_closing(*, board: str): + assert board == "cassandra" + return Closing() + + @classmethod + def list_tasks(cls, _connection, *, include_archived: bool): + assert include_archived is False + return cls.tasks + + @classmethod + def set_workspace_path(cls, _connection, task_id: str, path: Path): + cls.moved.append((task_id, str(path))) + + active = Path("/opt/data/workspace/projects/cassandra-hermes-v69") + coordinator._migrate_open_cassandra_tasks(FakeKanban, active) + + assert FakeKanban.moved == [("queued", str(active))]