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
|
# Hermes project coordinator
|
||||||
|
|
||||||
Use the native Project and Kanban surfaces. Cassandra uses project and board
|
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,
|
Put objectives needing decomposition in Triage. Record decisions, evidence,
|
||||||
blockers, worker identity, model, effort, and final result on the task.
|
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: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
- {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}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@ -361,6 +362,7 @@ spec:
|
|||||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
- {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}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
@ -683,6 +685,7 @@ spec:
|
|||||||
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
- {name: CLAUDE_CONFIG_DIR, value: /opt/data/home/.claude}
|
||||||
- {name: PYTHONPATH, value: /opt/hermes}
|
- {name: PYTHONPATH, value: /opt/hermes}
|
||||||
- {name: HERMES_ROUTING_CATALOG_PATH, value: /routing-catalog/catalog.json}
|
- {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}
|
- {name: PATH, value: /opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/usr/local/bin:/usr/bin:/bin}
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
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"
|
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:
|
def bootstrap_cassandra(root: Path) -> None:
|
||||||
"""Create the initial isolated board and project without resetting user state."""
|
"""Create the initial isolated board and project without resetting user state."""
|
||||||
os.environ["HERMES_HOME"] = str(root)
|
os.environ["HERMES_HOME"] = str(root)
|
||||||
from hermes_cli import kanban_db as kb
|
from hermes_cli import kanban_db as kb
|
||||||
from hermes_cli import projects_db as pdb
|
from hermes_cli import projects_db as pdb
|
||||||
|
|
||||||
|
workspace = cassandra_workspace()
|
||||||
first_create = not kb.board_exists("cassandra")
|
first_create = not kb.board_exists("cassandra")
|
||||||
kb.create_board(
|
kb.create_board(
|
||||||
"cassandra",
|
"cassandra",
|
||||||
name="Cassandra",
|
name="Cassandra",
|
||||||
description="Objectives, implementation tasks, reviews, and evidence for Cassandra.",
|
description="Objectives, implementation tasks, reviews, and evidence for Cassandra.",
|
||||||
default_workdir=str(CASSANDRA_PATH),
|
default_workdir=str(workspace),
|
||||||
)
|
)
|
||||||
if first_create:
|
if first_create:
|
||||||
kb.set_current_board("cassandra")
|
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:
|
with pdb.connect_closing() as connection:
|
||||||
project = pdb.get_project(connection, "cassandra")
|
project = pdb.get_project(connection, "cassandra")
|
||||||
if project is None:
|
if project is None:
|
||||||
@ -51,8 +78,8 @@ def bootstrap_cassandra(root: Path) -> None:
|
|||||||
connection,
|
connection,
|
||||||
name="Cassandra",
|
name="Cassandra",
|
||||||
slug="cassandra",
|
slug="cassandra",
|
||||||
folders=[str(CASSANDRA_PATH)],
|
folders=project_folders,
|
||||||
primary_path=str(CASSANDRA_PATH),
|
primary_path=str(workspace),
|
||||||
description="Cassandra project objectives and coordinated delivery.",
|
description="Cassandra project objectives and coordinated delivery.",
|
||||||
board_slug="cassandra",
|
board_slug="cassandra",
|
||||||
)
|
)
|
||||||
@ -66,7 +93,20 @@ def bootstrap_cassandra(root: Path) -> None:
|
|||||||
description="Cassandra project objectives and coordinated delivery.",
|
description="Cassandra project objectives and coordinated delivery.",
|
||||||
board_slug="cassandra",
|
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:
|
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()
|
token = env_values.get("GITEA_TOKEN", "").strip()
|
||||||
if shutil.which("git") is None:
|
if shutil.which("git") is None:
|
||||||
return "git-unavailable"
|
return "git-unavailable"
|
||||||
if (CASSANDRA_PATH / ".git").is_dir():
|
if (CASSANDRA_BASE_PATH / ".git").is_dir():
|
||||||
if not token:
|
if not token:
|
||||||
return "ready; fetch skipped until Gitea token is configured"
|
return "ready; fetch skipped until Gitea token is configured"
|
||||||
command = [
|
command = [
|
||||||
"git",
|
"git",
|
||||||
"-C",
|
"-C",
|
||||||
str(CASSANDRA_PATH),
|
str(CASSANDRA_BASE_PATH),
|
||||||
"fetch",
|
"fetch",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--prune",
|
"--prune",
|
||||||
"origin",
|
"origin",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
CASSANDRA_PATH.mkdir(parents=True, exist_ok=True)
|
CASSANDRA_BASE_PATH.mkdir(parents=True, exist_ok=True)
|
||||||
if any(CASSANDRA_PATH.iterdir()):
|
if any(CASSANDRA_BASE_PATH.iterdir()):
|
||||||
return "unmanaged-nonempty-directory"
|
return "unmanaged-nonempty-directory"
|
||||||
if not token:
|
if not token:
|
||||||
return "awaiting-gitea-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 = os.environ.copy()
|
||||||
child_env.update(env_values)
|
child_env.update(env_values)
|
||||||
try:
|
try:
|
||||||
@ -133,7 +179,7 @@ def refresh_once(root: Path) -> dict[str, Any]:
|
|||||||
"projects": {
|
"projects": {
|
||||||
"cassandra": {
|
"cassandra": {
|
||||||
"board": "cassandra",
|
"board": "cassandra",
|
||||||
"workspace": str(CASSANDRA_PATH),
|
"workspace": str(cassandra_workspace()),
|
||||||
"repository": CASSANDRA_REMOTE,
|
"repository": CASSANDRA_REMOTE,
|
||||||
"state": repo_state,
|
"state": repo_state,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -219,7 +219,16 @@ data:
|
|||||||
the fastest target at the safety floor, never below it. An explicit
|
the fastest target at the safety floor, never below it. An explicit
|
||||||
available provider or model wins at the safety floor.
|
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,
|
formatting, lookup, and continuity. Prefer Codex for implementation,
|
||||||
debugging, tests, commands, and repository work. Prefer Claude for
|
debugging, tests, commands, and repository work. Prefer Claude for
|
||||||
architecture, ambiguity, synthesis, risk analysis, adversarial analysis,
|
architecture, ambiguity, synthesis, risk analysis, adversarial analysis,
|
||||||
@ -227,7 +236,7 @@ data:
|
|||||||
select a provider stated to be unavailable, failed, exhausted, rate-limited,
|
select a provider stated to be unavailable, failed, exhausted, rate-limited,
|
||||||
or out of capacity; use the other provider at the same floor.
|
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,
|
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low,
|
||||||
medium=claude_sonnet_medium, high=claude_sonnet_high,
|
medium=claude_sonnet_medium, high=claude_sonnet_high,
|
||||||
xhigh=claude_opus_xhigh; local low=local_qwen_low and local
|
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
|
the fastest target at the safety floor, never below it. An explicit
|
||||||
available provider or model wins at the safety floor.
|
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,
|
formatting, lookup, and continuity. Prefer Codex for implementation,
|
||||||
debugging, tests, commands, and repository work. Prefer Claude for
|
debugging, tests, commands, and repository work. Prefer Claude for
|
||||||
architecture, ambiguity, synthesis, risk analysis, adversarial analysis,
|
architecture, ambiguity, synthesis, risk analysis, adversarial analysis,
|
||||||
@ -289,7 +307,7 @@ data:
|
|||||||
select a provider stated to be unavailable, failed, exhausted, rate-limited,
|
select a provider stated to be unavailable, failed, exhausted, rate-limited,
|
||||||
or out of capacity; use the other provider at the same floor.
|
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,
|
high=codex_sol_high, xhigh=codex_sol_xhigh; Claude low=claude_haiku_low,
|
||||||
medium=claude_sonnet_medium, high=claude_sonnet_high,
|
medium=claude_sonnet_medium, high=claude_sonnet_high,
|
||||||
xhigh=claude_opus_xhigh; local low=local_qwen_low and local
|
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(
|
assert any(
|
||||||
target.startswith("local_") for target in routes[route_name]["targets"]
|
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"]
|
assert "max_output_tokens" not in routes["worker_auto_maximum"]
|
||||||
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
||||||
"model_gate.py"
|
"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_path.is_file()
|
||||||
assert status["projects"]["cassandra"]["state"] == "ready"
|
assert status["projects"]["cassandra"]["state"] == "ready"
|
||||||
assert "do-not-report" not in status_path.read_text(encoding="utf-8")
|
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