hermes: restore image and Cassandra continuity
All checks were successful
Tests / Declarative: Post Actions passed: 253

This commit is contained in:
jenkins 2026-08-12 08:30:01 -03:00
parent 4d91c75e66
commit a4d2ecf40e
6 changed files with 147 additions and 17 deletions

View File

@ -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.

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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"

View File

@ -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))]