Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -3534,6 +3534,12 @@ def _cmd_lane_model_set(args: argparse.Namespace) -> int:
if provider_error:
print(f"kanban: {provider_error}", file=sys.stderr)
return 2
from hermes_cli.model_policy import pinned_sub_provider_error

pin_error = pinned_sub_provider_error(model, provider)
if pin_error:
print(f"kanban: {pin_error}", file=sys.stderr)
return 2

firepower_reason = parsed.firepower if parsed else None
guard_error = firepower_guard_error(model, firepower_reason)
Expand Down
17 changes: 15 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5870,9 +5870,13 @@ def create_task(
reasoning_effort = normalize_reasoning_effort(reasoning_effort)
if provider_override and not model_override:
raise ValueError("provider_override requires a model_override")
from hermes_cli.model_policy import validate_route_provider

validate_route_provider(model_override, provider_override)
model_override, provider_override = _resolve_stored_model_pair(
model_override, provider_override
)
validate_route_provider(model_override, provider_override)
from hermes_cli.model_policy import validate_worker_model

flagship_override_reason = validate_worker_model(
Expand Down Expand Up @@ -6543,7 +6547,11 @@ def _validate_model_override_args(
raise ValueError("provider_override requires a model_override")
if not model:
provider = None
from hermes_cli.model_policy import validate_route_provider

validate_route_provider(model, provider)
model, provider = _resolve_stored_model_pair(model, provider)
validate_route_provider(model, provider)
# Main's flagship ban (model_policy) is the one predicate. A flagship
# route is only writable together with the ``flagship override:`` comment
# the dispatcher's flagship gate accepts, so a route this layer writes can
Expand Down Expand Up @@ -14438,9 +14446,11 @@ def set_task_model(
``task_id`` returns ``0`` (never a silent success), so callers can tell
a real write from a no-op.
"""
resolved_model, resolved_provider = _resolve_stored_model_pair(model, None)
from hermes_cli.model_policy import validate_worker_model
from hermes_cli.model_policy import validate_route_provider, validate_worker_model

validate_route_provider(model, None)
resolved_model, resolved_provider = _resolve_stored_model_pair(model, None)
validate_route_provider(resolved_model, resolved_provider)
validate_worker_model(resolved_model)
if not resolved_model:
resolved_provider = None
Expand Down Expand Up @@ -20629,6 +20639,9 @@ def set_lane_model_override(
upsert, so an operator extending a window never stacks duplicate rows.
"""

from hermes_cli.model_policy import validate_route_provider

validate_route_provider(model, provider)
created = int(time.time()) if now is None else int(now)
key = (assignee or "").strip()
with write_txn(conn):
Expand Down
61 changes: 61 additions & 0 deletions hermes_cli/model_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from __future__ import annotations

import re
from collections.abc import Mapping
from typing import Any, Optional

Expand Down Expand Up @@ -89,6 +90,66 @@ def override_comment(reason: str) -> str:
return f"{FLAGSHIP_OVERRIDE_COMMENT_PREFIX} {reason.strip()}"


# ---------------------------------------------------------------------------
# Single-sub pin refusal (t_141135aa, Ace 2026-09-25). Kanban routes are
# worker lanes: they ride the POOLS (claude-bpr / claude-apr), never one
# subscription. ``claude-apx-N`` / ``claude-bpx-N`` pin one sub, and the
# pre-rename aliases (``claude-api-proxy`` = claude-apx-0 = Ace's personal Mac
# sub, ``claude-bridge`` = claude-bpx-0, ``-fN`` = sub N) are the same pins
# under a name that hides it. Same patterns as the home fleet-config-lint
# ``provider_naming_floor`` rule.
# ---------------------------------------------------------------------------

PRE_RENAME_PROVIDER_ALIAS_RE = re.compile(
r"^(?:claude-api-proxy(?:-f\d+|-failover\d+)?|claude-proxy(?:-f\d+)?"
r"|claude-subscription-proxy|claude-bridge(?:-f\d+|-failover\d+|-fallback\d+)?"
r"|claude-cli-bridge)$"
)
SINGLE_SUB_PIN_RE = re.compile(r"^claude-(?:apx|bpx)-\d+$")


def _route_provider_names(model: Optional[str], provider: Optional[str]) -> list[str]:
names = []
for value in (provider, (model or "").split("/", 1)[0] if "/" in (model or "") else None):
name = str(value or "").strip().casefold()
if name:
names.append(name)
return names


def pinned_sub_provider_error(
model: Optional[str], provider: Optional[str] = None
) -> Optional[str]:
"""Refusal text when a route pins one Claude sub (or a pre-rename alias of one).

Checks the explicit provider and a ``provider/`` prefix on the model.
Returns ``None`` for pool / non-Claude routes.
"""
for name in _route_provider_names(model, provider):
lane = "bpr" if "bpx" in name or "bridge" in name else "apr"
if PRE_RENAME_PROVIDER_ALIAS_RE.match(name):
return (
f"provider '{name}' is a pre-rename alias of a single-sub pin "
"(claude-api-proxy = claude-apx-0 = Ace's personal Mac sub). "
f"Workers ride the pool: use provider claude-{lane} "
"(lane-model set, never a per-sub pin)."
)
if SINGLE_SUB_PIN_RE.match(name):
return (
f"provider '{name}' pins one Claude subscription; workers never pin "
f"claude-apx-N/claude-bpx-N. Use the pool provider claude-{lane} "
"(lane-model set for a lane-wide route)."
)
return None


def validate_route_provider(model: Optional[str], provider: Optional[str] = None) -> None:
"""Raise ``ValueError`` for a single-sub pin route (see above)."""
error = pinned_sub_provider_error(model, provider)
if error:
raise ValueError(error)


# ---------------------------------------------------------------------------
# Batch / lane routing helpers (set-model --where, lane-model). These consume
# ``flagship_model_match`` above; they add alias resolution so a config alias
Expand Down
12 changes: 7 additions & 5 deletions tests/hermes_cli/test_kanban_model_override.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,14 @@ def test_vendor_prefixed_override_forces_provider_on_spawn(
encoding="utf-8",
)
with kb.connect() as conn:
tid = kb.create_task(
conn,
title="sonnet task",
assignee="worker",
model_override="claude-apx-6/claude-sonnet-4-5",
tid = kb.create_task(conn, title="sonnet task", assignee="worker")
# Legacy row: writers refuse single-sub pins now (t_141135aa), but rows
# written before that still dispatch and must keep the prefix split.
conn.execute(
"UPDATE tasks SET model_override=? WHERE id=?",
("claude-apx-6/claude-sonnet-4-5", tid),
)
conn.commit()
task = kb.get_task(conn, tid)

argv = _spawn_argv_for(monkeypatch, task)
Expand Down
116 changes: 116 additions & 0 deletions tests/hermes_cli/test_kanban_pinned_sub_refusal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Kanban refuses single-sub pin routes at set time (t_141135aa, Ace 2026-09-25).

Kanban routes are worker lanes and ride the pools (claude-bpr / claude-apr).
A card or lane pinned to ``claude-apx-N`` / ``claude-bpx-N`` — or to a
pre-rename alias of one (``claude-api-proxy`` is claude-apx-0, Ace's personal
Mac sub) — is refused by every writer, with the pool alternative named.
"""
from __future__ import annotations

from pathlib import Path

import pytest

from hermes_cli import kanban as kc
from hermes_cli import kanban_db as kb
from hermes_cli.model_policy import pinned_sub_provider_error


@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home


PINS = [
"claude-api-proxy", "claude-proxy", "claude-subscription-proxy", "claude-bridge",
"claude-api-proxy-f3", "claude-proxy-f12", "claude-bridge-f7",
"claude-api-proxy-failover2", "claude-bridge-fallback4",
"claude-apx-0", "claude-apx-15", "claude-bpx-3",
]
POOLS = ["claude-bpr", "claude-apr", "openai-codex", None, "claude-apx", "claude-bridge-x"]


@pytest.mark.parametrize("provider", PINS)
def test_predicate_refuses_every_pin_spelling_and_names_the_pool(provider):
err = pinned_sub_provider_error("claude-opus-5-5", provider)
assert err and provider in err
assert ("claude-bpr" in err) or ("claude-apr" in err)
# the same pin carried as a provider/ prefix on the model string
assert pinned_sub_provider_error(f"{provider}/claude-opus-5-5", None)
# case-insensitive
assert pinned_sub_provider_error("claude-opus-5-5", provider.upper())


@pytest.mark.parametrize("provider", POOLS)
def test_predicate_allows_pools_and_other_vendors(provider):
assert pinned_sub_provider_error("claude-opus-5-5", provider) is None


def test_bridge_lane_names_bpr_and_proxy_lane_names_apr():
assert "claude-bpr" in pinned_sub_provider_error("m", "claude-bpx-2")
assert "claude-apr" in pinned_sub_provider_error("m", "claude-api-proxy")


def test_create_task_refuses_pin(kanban_home):
with kb.connect() as conn:
with pytest.raises(ValueError, match="claude-apx-0"):
kb.create_task(conn, title="x", assignee="daedalus-opus",
model_override="claude-opus-5-5", provider_override="claude-apx-0")
assert conn.execute("SELECT count(*) FROM tasks").fetchone()[0] == 0


def test_set_model_override_refuses_pin_and_keeps_existing_route(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", assignee="argus",
model_override="claude-opus-5-5", provider_override="claude-bpr")
with pytest.raises(ValueError, match="pre-rename alias"):
kb.set_model_override(conn, tid, "claude-opus-5-5", "claude-api-proxy")
with pytest.raises(ValueError, match="claude-apx-15"):
kb.set_model_override(conn, tid, "claude-apx-15/claude-opus-5-5")
task = kb.get_task(conn, tid)
assert (task.model_override, task.provider_override) == ("claude-opus-5-5", "claude-bpr")
events = conn.execute(
"SELECT count(*) FROM task_events WHERE task_id=? AND kind='model_override_set'",
(tid,)).fetchone()[0]
assert events == 0


def test_set_task_model_refuses_prefixed_pin(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", assignee="argus")
with pytest.raises(ValueError, match="claude-bpx-3"):
kb.set_task_model(conn, tid, "claude-bpx-3/claude-opus-5-5")


def test_lane_model_db_layer_refuses_pin(kanban_home):
with kb.connect() as conn:
with pytest.raises(ValueError, match="claude-apr"):
kb.set_lane_model_override(conn, provider="claude-apx-0", model="claude-opus-5-5",
expires_at=10**10, reason="r", assignee="daedalus-opus")
assert kb.list_lane_model_overrides(conn, include_expired=True) == []


def test_lane_model_cli_refuses_pin_with_pool_hint(kanban_home, capsys):
(kanban_home / "config.yaml").write_text(
"providers:\n claude-apx-0:\n base_url: http://127.0.0.1:9999/v1\n api_key: t\n",
encoding="utf-8")
out = kc.run_slash("lane-model set claude-apx-0/claude-opus-5-5 --ttl 1h --reason cap")
captured = capsys.readouterr()
text = (out or "") + captured.out + captured.err
assert "claude-apr" in text, text
with kb.connect() as conn:
assert kb.list_lane_model_overrides(conn, include_expired=True) == []


def test_pool_route_still_writes(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="x", assignee="argus")
assert kb.set_model_override(conn, tid, "claude-opus-5-5", "claude-bpr")
kb.set_lane_model_override(conn, provider="claude-bpr", model="claude-opus-5-5",
expires_at=10**10, reason="r", assignee="argus")
assert kb.get_lane_model_override(conn, assignee="argus").provider == "claude-bpr"
8 changes: 5 additions & 3 deletions tests/hermes_cli/test_kanban_pool_ratelimit_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,9 +867,11 @@ def test_diag_shows_backoff_hold():

def _rl_burst(conn, ends, provider="claude-apr"):
"""A finished card whose runs closed rate_limited at ``ends`` on ``provider``."""
tid = kb.create_task(conn, title="storm", assignee="z", model_override="m",
provider_override=provider)
conn.execute("UPDATE tasks SET status='done', completed_at=strftime('%s','now') WHERE id=?", (tid,))
tid = kb.create_task(conn, title="storm", assignee="z")
# Raw write: a historical row may carry a single-sub pin (claude-apx-N) that
# the writers now refuse (t_141135aa); the circuit must still key on it.
conn.execute("UPDATE tasks SET model_override='m', provider_override=?, status='done', "
"completed_at=strftime('%s','now') WHERE id=?", (provider, tid))
_runs(conn, tid, [("rate_limited", e) for e in ends])
return tid

Expand Down
Loading