hermes: enforce native provider availability for manual assignments
Some checks failed
Tests / Declarative: Post Actions failed: 42, skipped: 89, passed: 3938

This commit is contained in:
jenkins 2026-09-13 19:26:44 -05:00
parent 18f5cfdbb5
commit 82cc28b383
4 changed files with 96 additions and 61 deletions

View File

@ -71,14 +71,21 @@ model list; it does not run a generation just to discover a model. A provider,
catalog, or evaluation outage preserves the last known-good routes and records catalog, or evaluation outage preserves the last known-good routes and records
the refresh as deferred, so discovery does not silently become a routing bypass. the refresh as deferred, so discovery does not silently become a routing bypass.
Existing pull-request continuation is available through coordinator-owned Existing pull-request continuation stays on one recorded PR and branch throughout
lineage: `kanban_continue_pr.py` validates the root task, recorded PR, branch, repair and review. `kanban_continue_pr.py` validates the root, PR, branch, and
and current head before queuing a repair on that same PR. A signed continuation current head before delegation; inspect the current head diff and wait for the
worker may update that exact owned ref through the SCM broker. Publication-only delegated result before creating a review or repair follow-up. A signed
resume for a completed but unpublished SCM result is still being finalized; continuation updates only that owned ref through the SCM broker, never creates
until its exact-run ownership, evidence preservation, and bounded finite-retry a replacement PR or adopts a local checkout.
semantics are confirmed, keep the workspace commit and structured result
recoverable and do not claim that path is live. A clean, broker-refused publication retains its exact completed evidence and
may receive one automatic retry after a five-minute backoff. The retry opens the
same child only when the current native blocked event matches the coordinator's
receipt fence, root link, and terminal pool evidence. A later human block,
cancellation, head change, missing receipt, or failed fence check leaves the
card blocked for operator review. Publication retries use a fresh signed run and
must republish the preserved head without a model call; current-head review
still follows a broker-confirmed publication.
## Private Jetson voice: multilingual TTS policy ## Private Jetson voice: multilingual TTS policy
@ -471,56 +478,40 @@ A `voice` field from a browser is never honoured at any hop.
## Distributed execution-pool rollout boundary ## Distributed execution-pool rollout boundary
- Do not reconcile the pool until the approved credential owner has provisioned - Each execution ordinal keeps its own durable task and provider-session state.
six distinct Vault fields: `execution_worker_{0,1,2}_claude_credentials_json` Do not copy, delete, or reconstruct provider credentials or session paths to
and `execution_worker_{0,1,2}_codex_auth_json`. Each ordinal needs an recover a worker; bind the trusted task session and let the worker defer when
independent account or refresh-token lineage; copying one rotating refresh that state is unavailable.
token into multiple fields recreates the lost-update failure this design - Each worker and its mediator share an RWX workspace so either can reschedule
prevents. during a drain. Provider-session and tools claims remain per-ordinal RWO,
- Worker credential files live on separate ordinal-owned RWO `provider-access` keeping private state isolated while retaining pinned CLI installations.
claims. Provider refresh updates these durable private copies across Pod - The durable tools claim records a pinned CLI version only after the expected
restarts; they are never synchronized back to Vault. Rotate one bootstrap binaries are executable; a missing or pruned binary causes a bounded reinstall.
credential at a time through the reviewed Vault workflow and reinitialize - The native execution pool uses its authenticated Claude provider lane for
only that ordinal after human approval. execution. Sol and Astra are models selected for advanced/frontier routes in
- Existing task worktrees remain on the single local owner lane. Only tasks Hermes operator/chat routing; they do not create a separate credential prerequisite
without `workspace_path` enter the distributed pool, where each ordinal owns for an execution ordinal.
one RWO checkout. Repository and base-branch identity comes from the canonical - The direct CLI lane may continue only an existing owned workspace. Pathless
board registry rather than task-supplied metadata. work belongs to the pool, and a trusted continuation always derives its
- Model Pods have no pool key, broker mount, broker egress, or Kubernetes token. repository and branch from the recorded PR lineage rather than a legacy local
Ordinal mediator Deployments are separate network identities that co-mount the checkout.
matching workspace PVC; they alone authenticate exact-run results and reach the - Model pods have no pool key, broker mount, broker egress, or Kubernetes token.
PR14 SCM broker. Per-ordinal mediator deployments co-mount the matching workspace state; they
- The ordinal workspace claim is `ReadWriteMany` because the worker and its alone authenticate exact-run results and reach the SCM broker.
mediator both mount it. With `ReadWriteOnce` a drain or preemption that moved - `lease_failed` is durable retryable state. It is retained until native Kanban
only one of them left the other holding the attachment on the old node, and confirms the exact run is finalized or stale; do not delete a deferred row to
the ordinal deadlocked on Multi-Attach until an operator deleted a Pod. force new work.
Colocation is now a scheduling preference and both Pods run at `scavenger` - Ordinary work creates an allowed broker branch. A trusted continuation instead
priority, so either can be evicted and rescheduled on its own. There is clones the latest verified head of its recorded PR branch and receives a
deliberately no PodDisruptionBudget: this is a best-effort pool and a budget signed grant for that same ref. It never force-pushes or opens a replacement
would impede node drains. Claims only one Pod mounts stay `ReadWriteOnce`. PR.
- `lease_failed` is a *retryable* durable state, not a terminal one. It means the - SCM broker availability is a runtime condition for mediated SCM operations,
ordinal was released but Kanban has not confirmed the outcome, so every not a Flux rollout gate for Hermes. Normal Hermes image, chat, model, and
maintenance pass retries it. Only `finalized` and `stale` are terminal, and tool rollouts continue while the broker is unavailable; publication work
only terminal rows are garbage-collected, so no run is dropped before the pool defers until it returns.
knows what happened to it. If the log keeps reporting a deferred lease park for - The pool remains outside the `hermes` Kustomization health checks because it
the same run, Kanban is refusing an exact-run park on a task it still reports is best-effort capacity. Verify the mounted config revision, protocol
as running: inspect that task rather than deleting the row. readiness, and worker/mediator health after a reviewed rollout.
- Ordinary work publishes a newly created broker branch. A trusted
coordinator-issued continuation is the sole exception: it private-clones the
latest verified head of the recorded PR branch and receives a signed grant to
update that exact ref. It never adopts a caller-provided ref, force-pushes, or
creates a replacement PR. A submission that cannot land blocks the run with
the reason and leaves the commits on the ordinal's workspace, so prior work is
recoverable.
- Provider CLIs install once per pinned version onto a durable per-ordinal
`tools` claim, re-verified against the real binaries so a pruned cache
reinstalls. The pool is intentionally absent from the `hermes` Kustomization's
`healthChecks`: gating that 10m window on a best-effort pool would stall
`hermes-chat` and `hermes-observer-bindings`, which `dependsOn: hermes`.
- A hashed execution-pool ConfigMap, protocol-version readiness checks, and
versioned SCM boundary name make code/config changes controlled rollouts.
Verify the config revision, protocol readiness, and worker/mediator health
after Flux applies a reviewed rollout.
## Your shortest path to fluency ## Your shortest path to fluency

View File

@ -22,7 +22,7 @@ spec:
annotations: annotations:
ai.bstein.dev/role: project-coordinator ai.bstein.dev/role: project-coordinator
ai.bstein.dev/router-wire-contract: ollama-numeric-keepalive ai.bstein.dev/router-wire-contract: ollama-numeric-keepalive
ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/execution: Hermes Kanban with isolated Claude workers and credential-owning Switchyard lanes
ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback
ai.bstein.dev/placement: primary amd64 accelerator titan-22; arm64 rpi5 fleet fallback; storage-backbone nodes excluded ai.bstein.dev/placement: primary amd64 accelerator titan-22; arm64 rpi5 fleet fallback; storage-backbone nodes excluded
ai.bstein.dev/config-rev: "20260913-soteria-kanban-recovery-v2" ai.bstein.dev/config-rev: "20260913-soteria-kanban-recovery-v2"

View File

@ -15,6 +15,7 @@ from pathlib import Path
from typing import Any from typing import Any
import cli_lane_runner import cli_lane_runner
import execution_pool_resume import execution_pool_resume
from cli_lane_routing import parse_assignee_capability
from execution_pool_protocol import ( from execution_pool_protocol import (
ProtocolError, ProtocolError,
atomic_json, atomic_json,
@ -288,6 +289,10 @@ def execute(assignment: dict[str, Any]) -> None:
if not _client("finish", binding=binding, payload=terminal, title=title, body=body).get("ack", {}).get("accepted"): if not _client("finish", binding=binding, payload=terminal, title=title, body=body).get("ack", {}).get("accepted"):
raise ProtocolError("coordinator did not accept the publication retry") raise ProtocolError("coordinator did not accept the publication retry")
return return
assignee = str(payload.get("assignee") or "cli-auto")
requested_provider, _effort, _capability = parse_assignee_capability(assignee)
if requested_provider == DISABLED_PROVIDER:
raise ProtocolError("assignment selects a disabled provider")
_bind_provider_sessions(assignment) _bind_provider_sessions(assignment)
workspace = Path(str(assignment.get("workspace") or "")).resolve(strict=True) workspace = Path(str(assignment.get("workspace") or "")).resolve(strict=True)
workspace.relative_to((ROOT / "runs").resolve()) workspace.relative_to((ROOT / "runs").resolve())
@ -314,9 +319,8 @@ def execute(assignment: dict[str, Any]) -> None:
except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError): except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError):
return False return False
context = str(payload.get("context") or "") context = str(payload.get("context") or "")
assignee = str(payload.get("assignee") or "cli-auto")
excluded = None excluded = None
if assignee == "cli-auto": if requested_provider is None:
excluded = DISABLED_PROVIDER or cli_lane_runner.fresh_unavailable_provider() excluded = DISABLED_PROVIDER or cli_lane_runner.fresh_unavailable_provider()
route = cli_lane_runner.select_route( route = cli_lane_runner.select_route(
context, context,

View File

@ -122,6 +122,46 @@ def test_execute_completed_clean_result_refreshes_and_finishes_exact_run(
assert state["terminal_at"] > 0 and state["baseline_sha"] == "a" * 40 assert state["terminal_at"] > 0 and state["baseline_sha"] == "a" * 40
@pytest.mark.parametrize("assignee", ["cli-codex-high", "CLI-CODEX-FRONTIER-XHIGH"])
def test_execute_rejects_an_explicitly_disabled_provider_before_any_worker_action(
tmp_path, monkeypatch, assignee
):
"""An unavailable native lane cannot be selected by a manual assignee."""
exact = assignment(payload={
"context": "safe objective", "assignee": assignee,
"deadline_unix": 10_000_000_000, "max_runtime_seconds": 3600,
})
exact, _workspace, calls, providers, routes, _refreshed = prepare_execute(
tmp_path, monkeypatch, item=exact
)
monkeypatch.setattr(worker, "DISABLED_PROVIDER", "codex")
monkeypatch.setattr(
worker, "_bind_provider_sessions", lambda _a: pytest.fail("staged provider state")
)
with pytest.raises(protocol.ProtocolError, match="disabled provider"):
worker.execute(exact)
assert calls == [] and providers == [] and routes == []
def test_execute_normalizes_auto_before_excluding_disabled_provider(tmp_path, monkeypatch):
"""Whitespace and case cannot bypass the automatic disabled-provider guard."""
exact = assignment(payload={
"context": "safe objective", "assignee": " CLI-AUTO ",
"deadline_unix": 10_000_000_000, "max_runtime_seconds": 3600,
})
exact, workspace, _calls, _providers, routes, _refreshed = prepare_execute(
tmp_path, monkeypatch, item=exact
)
exact["workspace"] = str(workspace)
monkeypatch.setattr(worker, "DISABLED_PROVIDER", "codex")
worker.execute(exact)
assert routes[0][1]["exclude_provider"] == "codex"
@pytest.mark.parametrize("summary", ["x" * 300, "界" * 300, "Ж" * 300]) @pytest.mark.parametrize("summary", ["x" * 300, "界" * 300, "Ж" * 300])
def test_execute_titles_are_accepted_by_gitea_draft_policy(tmp_path, monkeypatch, summary): def test_execute_titles_are_accepted_by_gitea_draft_policy(tmp_path, monkeypatch, summary):
"""Ordinary worker publication preserves Gitea's character and byte limits.""" """Ordinary worker publication preserves Gitea's character and byte limits."""