hermes: restore image and Cassandra continuity
All checks were successful
Tests / Declarative: Post Actions passed: 253
All checks were successful
Tests / Declarative: Post Actions passed: 253
This commit is contained in:
parent
4d91c75e66
commit
a4d2ecf40e
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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))]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user