Implement the harness contract and prove it against rein-aharness (GLAS-WP-0001-T02/T03/T04)
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 3s

- src/glas_harness/contract.py: Rein ABC (start_session/dispatch_tool/
  end_session), SandboxHandle/ToolCall/ToolResult, per docs/harness-contract.md.
- reins/rein_aharness.py: adapter around the rein-aharness CLI. Collapses
  a whole `agent-harness run` into one dispatch_tool call for now (no
  per-tool hooks yet — tracked in rein-aharness HARNESS-WP-0002-T03);
  verifies success via new-commit detection, mirroring rein-aharness's
  own success signal.
- gateway.py + cli.py: resolves a sand-boxer sandbox, runs one task
  through a rein, tears the sandbox down.
- registry/reins/*.yaml (rein-aharness implemented, rein-openweights
  planned) and profiles/harness.agent-dev{,-local}.yaml, pairing with
  sand-boxer's profile.agent-dev and the new profile.bwrap-local.

Tested against a real local git repo + mocked SandboxManager/CLI
subprocess (10 tests, all passing). A real live run against the actual
Claude Code CLI is deliberately left for a human-triggered follow-up —
not executed autonomously since it spends real API credits/credentials.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-07-26 13:02:59 +02:00
parent 97a50fc780
commit 5681fce787
16 changed files with 556 additions and 4 deletions

1
.gitignore vendored
View file

@ -180,3 +180,4 @@ cython_debug/
.claude/*
!.claude/rules/
!.claude/rules/*.md
.venv/

View file

@ -13,7 +13,7 @@
| task | GLAS-0001-T01 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md |
| task | GLAS-0001-T02 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md |
| task | GLAS-0001-T03 | todo | — | workplans/GLAS-0001-statehub-bootstrap.md |
| task | GLAS-WP-0001-T01 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
| task | GLAS-WP-0001-T01 | done | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
| task | GLAS-WP-0001-T02 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
| task | GLAS-WP-0001-T03 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
| task | GLAS-WP-0001-T04 | todo | — | workplans/GLAS-WP-0001-harness-router-foundation.md |

View file

@ -0,0 +1,10 @@
id: harness.agent-dev-local
version: "1.0.0"
rein: rein-aharness
sandbox_profile: profile.bwrap-local
tool_profile: green-commit-only
metadata:
latency_class: fast
# Pairs with sand-boxer's profile.bwrap-local (ext.bwrap, same-host,
# no SSH hop) — for CI/local dev where the compose-ssh remote-host
# round trip is unnecessary overhead.

View file

@ -0,0 +1,10 @@
id: harness.agent-dev
version: "1.0.0"
rein: rein-aharness
sandbox_profile: profile.agent-dev
tool_profile: green-commit-only
metadata:
latency_class: standard
# Pairs with sand-boxer's profile.agent-dev (ext.compose-ssh, remote
# host over SSH). Use harness.agent-dev-local for fast local iteration
# without the SSH hop.

30
pyproject.toml Normal file
View file

@ -0,0 +1,30 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "glas-harness"
version = "0.1.0"
description = "Meta-framework routing between concrete agent-harness backends (reins)"
requires-python = ">=3.11"
dependencies = [
"pydantic>=2.0",
"pyyaml>=6.0",
]
[project.scripts]
glas-harness = "glas_harness.cli:main"
[tool.uv.sources]
sandboxer = { path = "../sand-boxer", editable = true }
[project.optional-dependencies]
sandbox = ["sandboxer"]
dev = ["pytest>=8"]
[tool.hatch.build.targets.wheel]
packages = ["src/glas_harness"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

View file

@ -0,0 +1,16 @@
id: rein-aharness
title: Claude Code CLI rein
description: >
Governed, unattended/scheduled harness driving Claude Code CLI sessions.
Renamed from agent-harness (ADR-001). Deployed on Railiance for tenant
binky-control.
handler: glas_harness.reins.rein_aharness:ReinAharness
capabilities:
session_style: unattended
model_class: frontier
credential_source: rein-local
# rein-local: kaizen-agentic (blueprints), activity-core/issue-core
# (intake), OpenBao/ops-warden (credentials) — see
# rein-aharness/workplans/HARNESS-WP-0002-T04 for whether this stays
# rein-local or moves into glas-harness.
status: implemented

View file

@ -0,0 +1,13 @@
id: rein-openweights
title: OpenRouter open-weight agentic loop
description: >
Agentic tool-use harness driving current open-weight models via
OpenRouter/llm-connect, as an alternative to frontier-vendor CLIs.
Chartered by ADR-001; not yet implementing this contract.
handler: glas_harness.reins.rein_openweights:ReinOpenWeights
capabilities:
session_style: unattended
model_class: open-weight
credential_source: tbd
# tbd: resolved by GLAS-WP-0001-T06 before this rein is implemented.
status: planned

View file

42
src/glas_harness/cli.py Normal file
View file

@ -0,0 +1,42 @@
"""glas-harness CLI — minimal gateway invocation (GLAS-WP-0001-T04)."""
from __future__ import annotations
import argparse
import json
import sys
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="glas-harness")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="Run one task through a rein inside a sand-boxer sandbox")
run.add_argument("--sandbox-profile", required=True, help="e.g. profile.bwrap-local")
run.add_argument("--repo", required=True, help="Local repo path to mirror into the sandbox")
run.add_argument("--title", required=True)
run.add_argument("--description", required=True)
run.add_argument("--actor", default="agt")
run.add_argument("--project", default="glas-harness")
args = parser.parse_args(argv)
if args.command == "run":
from glas_harness.gateway import run_task_through_rein
result = run_task_through_rein(
sandbox_profile=args.sandbox_profile,
repo=args.repo,
title=args.title,
description=args.description,
actor=args.actor,
project=args.project,
)
print(json.dumps(result, indent=2))
return 0 if result["tool_ok"] else 1
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,54 @@
"""The harness contract a concrete rein implements.
See docs/harness-contract.md for the full session-lifecycle diagram and
GLAS-WP-0001-T01. glas-harness owns the outer loop (profile resolution,
sandbox request/teardown via sand-boxer, State Hub reporting, actor
attribution); a rein owns the inner agentic loop.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
@dataclass
class SandboxHandle:
"""What glas-harness got back from sand-boxer's create()."""
sandbox_id: str
host: str
reachability: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolCall:
name: str
args: dict[str, Any] = field(default_factory=dict)
actor: str = "agt"
@dataclass
class ToolResult:
ok: bool
output: str = ""
error: str | None = None
class Rein(ABC):
"""Base class for concrete harness backends (reins)."""
@abstractmethod
def start_session(
self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle
) -> dict[str, str]:
"""Begin an agent session bound to a sandbox. Returns a session handle."""
@abstractmethod
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
"""Run one tool call under the session's policy."""
@abstractmethod
def end_session(self, session: dict[str, str]) -> dict[str, str]:
"""Close the session. Returns a summary (commit sha, outcome, ...)."""

View file

@ -0,0 +1,72 @@
"""Minimal gateway proving the harness contract against rein-aharness.
GLAS-WP-0001-T04: resolve a sand-boxer profile, request a sandbox,
dispatch one rein-aharness task through the Rein contract, verify a
commit landed, tear the sandbox down. This is the parity proof gating
any later "retire rein-aharness as a standalone concern" conversation
it is not itself that conversation.
Requires the `sandbox` extra (sand-boxer installed as a sibling
editable dependency).
"""
from __future__ import annotations
from typing import Any
from sandboxer.core.manager import SandboxManager
from sandboxer.models import Consumer, SandboxCreateRequest
from glas_harness.contract import Rein, SandboxHandle, ToolCall
from glas_harness.reins.rein_aharness import ReinAharness
def run_task_through_rein(
*,
sandbox_profile: str,
repo: str,
title: str,
description: str,
rein: Rein | None = None,
actor: str = "agt",
project: str = "glas-harness",
manager: SandboxManager | None = None,
) -> dict[str, Any]:
"""Resolve `sandbox_profile`, run one task inside it via `rein`, verify, tear down.
Defaults to `ReinAharness` when no rein is supplied the only
implemented rein as of GLAS-WP-0001-T04. `rein-openweights` plugs in
the same way once REIN-OW-WP-0001 lands.
"""
manager = manager or SandboxManager()
rein = rein or ReinAharness()
request = SandboxCreateRequest(
profile=sandbox_profile,
inputs={"repo": repo},
consumer=Consumer(actor=actor, project=project),
)
status = manager.create(request)
try:
reachability = status.reachability.model_dump(mode="json") if status.reachability else {}
sandbox = SandboxHandle(
sandbox_id=status.sandbox_id, host=status.host or "", reachability=reachability
)
session = rein.start_session(
profile={"id": sandbox_profile},
inputs={"title": title, "description": description},
sandbox=sandbox,
)
tool_result = rein.dispatch_tool(session, ToolCall(name="run_task", actor=actor))
summary = rein.end_session(session)
return {
"sandbox_id": status.sandbox_id,
"tool_ok": tool_result.ok,
"tool_output": tool_result.output,
"tool_error": tool_result.error,
"summary": summary,
}
finally:
manager.destroy(status.sandbox_id)

View file

View file

@ -0,0 +1,102 @@
"""ReinAharness — glas-harness adapter around the rein-aharness CLI.
rein-aharness's `run` command performs an entire bounded agentic session
(persona load, Claude Code CLI subprocess, commit verification) as one
opaque unit it does not yet expose per-tool-call hooks from outside
(that refactor is tracked in
rein-aharness/workplans/HARNESS-WP-0002-T03). Until then, dispatch_tool
collapses the whole run into a single call rather than true per-tool
granularity; this adapter proves the contract's *shape* (start/dispatch/
end + sandbox handoff), not fine-grained tool interception yet.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Any
from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult
class ReinAharnessNotInstalled(RuntimeError):
pass
class ReinAharness(Rein):
def __init__(self, cli_bin: str = "agent-harness") -> None:
self.cli_bin = cli_bin
def _bin(self) -> str:
resolved = shutil.which(self.cli_bin)
if not resolved:
raise ReinAharnessNotInstalled(
f"'{self.cli_bin}' not found on PATH — install rein-aharness first"
)
return resolved
def start_session(
self, profile: dict[str, Any], inputs: dict[str, str], sandbox: SandboxHandle
) -> dict[str, str]:
target_repo = (
inputs.get("target_repo")
or sandbox.reachability.get("workspace_dir")
or sandbox.reachability.get("remote_dir")
)
if not target_repo:
raise ValueError("no target_repo resolvable from inputs or sandbox reachability")
task_file = inputs.get("task_file")
if not task_file:
task_spec = {
"title": inputs["title"],
"description": inputs["description"],
"target_repo": target_repo,
"agent": inputs.get("agent", "coach"),
}
fd = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", prefix="glas-harness-task-", delete=False
)
json.dump(task_spec, fd)
fd.close()
task_file = fd.name
return {
"sandbox_id": sandbox.sandbox_id,
"task_file": task_file,
"target_repo": target_repo,
"head_before": self._git_head(target_repo),
}
def dispatch_tool(self, session: dict[str, str], tool_call: ToolCall) -> ToolResult:
argv = [
self._bin(),
"run",
"--task-file",
session["task_file"],
"--no-hub",
"--no-metrics",
]
proc = subprocess.run(argv, capture_output=True, text=True)
ok = proc.returncode == 0
return ToolResult(ok=ok, output=proc.stdout, error=None if ok else proc.stderr)
def end_session(self, session: dict[str, str]) -> dict[str, str]:
head_after = self._git_head(session["target_repo"])
committed = bool(head_after) and head_after != session.get("head_before")
return {
"commit_sha": head_after or "",
"committed": str(committed),
}
@staticmethod
def _git_head(repo: str) -> str:
proc = subprocess.run(
["git", "-C", str(Path(repo).expanduser()), "rev-parse", "HEAD"],
capture_output=True,
text=True,
)
return proc.stdout.strip() if proc.returncode == 0 else ""

88
tests/test_gateway.py Normal file
View file

@ -0,0 +1,88 @@
from datetime import UTC, datetime
from unittest.mock import MagicMock
from sandboxer.models import Reachability, SandboxState, SandboxStatus
from glas_harness.contract import Rein, SandboxHandle, ToolCall, ToolResult
from glas_harness.gateway import run_task_through_rein
class _FakeRein(Rein):
def __init__(self) -> None:
self.calls: list[str] = []
def start_session(self, profile, inputs, sandbox: SandboxHandle):
self.calls.append("start_session")
assert sandbox.sandbox_id == "sbx1"
assert sandbox.reachability.get("workspace_dir") == "/tmp/ws"
return {"session": "s1"}
def dispatch_tool(self, session, tool_call: ToolCall) -> ToolResult:
self.calls.append("dispatch_tool")
assert tool_call.name == "run_task"
return ToolResult(ok=True, output="done")
def end_session(self, session):
self.calls.append("end_session")
return {"commit_sha": "deadbeef", "committed": "True"}
def _fake_status(sandbox_id: str = "sbx1") -> SandboxStatus:
now = datetime.now(UTC)
return SandboxStatus(
sandbox_id=sandbox_id,
profile_id="profile.bwrap-local",
extension_id="ext.bwrap",
state=SandboxState.READY,
consumer={"actor": "agt", "project": "glas-harness"},
host="localhost",
reachability=Reachability(host="localhost", pid="123", workspace_dir="/tmp/ws"),
created_at=now,
updated_at=now,
)
def test_run_task_through_rein_creates_and_destroys_sandbox() -> None:
manager = MagicMock()
manager.create.return_value = _fake_status()
rein = _FakeRein()
result = run_task_through_rein(
sandbox_profile="profile.bwrap-local",
repo="/tmp/repo",
title="t",
description="d",
rein=rein,
manager=manager,
)
assert rein.calls == ["start_session", "dispatch_tool", "end_session"]
manager.create.assert_called_once()
manager.destroy.assert_called_once_with("sbx1")
assert result["tool_ok"] is True
assert result["summary"]["committed"] == "True"
def test_run_task_through_rein_destroys_sandbox_even_on_failure() -> None:
manager = MagicMock()
manager.create.return_value = _fake_status()
class _FailingRein(_FakeRein):
def dispatch_tool(self, session, tool_call):
raise RuntimeError("boom")
rein = _FailingRein()
try:
run_task_through_rein(
sandbox_profile="profile.bwrap-local",
repo="/tmp/repo",
title="t",
description="d",
rein=rein,
manager=manager,
)
except RuntimeError:
pass
manager.destroy.assert_called_once_with("sbx1")

103
tests/test_rein_aharness.py Normal file
View file

@ -0,0 +1,103 @@
import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from glas_harness.contract import Rein, SandboxHandle, ToolCall
from glas_harness.reins.rein_aharness import ReinAharness, ReinAharnessNotInstalled
def test_is_rein_subclass() -> None:
assert issubclass(ReinAharness, Rein)
def test_bin_raises_if_not_installed() -> None:
rein = ReinAharness(cli_bin="does-not-exist-binary")
with pytest.raises(ReinAharnessNotInstalled):
rein._bin()
def test_start_session_writes_task_file_and_captures_head(tmp_path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
rein = ReinAharness()
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={"workspace_dir": str(repo)})
session = rein.start_session(
profile={"id": "harness.agent-dev-local"},
inputs={"title": "t", "description": "d"},
sandbox=sandbox,
)
assert session["target_repo"] == str(repo)
assert session["head_before"]
task_spec = json.loads(open(session["task_file"]).read())
assert task_spec["title"] == "t"
assert task_spec["target_repo"] == str(repo)
def test_start_session_requires_resolvable_target_repo() -> None:
rein = ReinAharness()
sandbox = SandboxHandle(sandbox_id="abc", host="localhost", reachability={})
with pytest.raises(ValueError, match="target_repo"):
rein.start_session(profile={}, inputs={"title": "t", "description": "d"}, sandbox=sandbox)
def test_dispatch_tool_invokes_agent_harness_cli() -> None:
rein = ReinAharness()
session = {"task_file": "/tmp/task.json"}
fake_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result) as run:
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
argv = run.call_args.args[0]
assert argv[:3] == ["/usr/bin/agent-harness", "run", "--task-file"]
assert result.ok is True
assert result.output == "ok"
def test_dispatch_tool_reports_failure() -> None:
rein = ReinAharness()
session = {"task_file": "/tmp/task.json"}
fake_result = MagicMock(returncode=1, stdout="", stderr="boom")
with patch.object(rein, "_bin", return_value="/usr/bin/agent-harness"):
with patch("glas_harness.reins.rein_aharness.subprocess.run", return_value=fake_result):
result = rein.dispatch_tool(session, ToolCall(name="run_task"))
assert result.ok is False
assert result.error == "boom"
def test_end_session_detects_new_commit(tmp_path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
rein = ReinAharness()
head_before = rein._git_head(str(repo))
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "task"], cwd=repo, check=True)
summary = rein.end_session({"target_repo": str(repo), "head_before": head_before})
assert summary["committed"] == "True"
assert summary["commit_sha"] != head_before
def test_end_session_no_new_commit(tmp_path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(["git", "commit", "-q", "--allow-empty", "-m", "init"], cwd=repo, check=True)
rein = ReinAharness()
head = rein._git_head(str(repo))
summary = rein.end_session({"target_repo": str(repo), "head_before": head})
assert summary["committed"] == "False"

View file

@ -42,7 +42,7 @@ glas-harness` entry per the registry-first-reuse principle.
```task
id: GLAS-WP-0001-T02
status: todo
status: done
priority: medium
state_hub_task_id: "c09e617b-a2d1-42c4-8c90-47ab0056c09c"
```
@ -58,7 +58,7 @@ fast local iteration without an SSH hop.
```task
id: GLAS-WP-0001-T03
status: todo
status: done
priority: medium
state_hub_task_id: "e599f276-a010-4284-bddd-a5a121b57de9"
```
@ -72,9 +72,20 @@ rein-aharness's adapter, verify commit, report to State Hub. This is the
parity proof gating any later "retire agent-harness as a standalone
concern" conversation — deliberately not attempted until this exists.
Wiring is built and unit-tested: `src/glas_harness/gateway.py` +
`reins/rein_aharness.py` implement start_session/dispatch_tool/
end_session, tested against a real local git repo and a mocked
`SandboxManager`/`agent-harness` subprocess (`tests/test_gateway.py`,
`tests/test_rein_aharness.py`). **Not yet done:** an actual live run
(`glas-harness run --sandbox-profile profile.bwrap-local ...`) against
the real Claude Code CLI — that spends real API credits/credentials and
was deliberately left for a human-triggered run rather than executed
autonomously. State Hub reporting from the gateway itself is also not
wired yet (only rein-aharness's own `--no-hub` path was exercised).
```task
id: GLAS-WP-0001-T04
status: todo
status: progress
priority: high
state_hub_task_id: "cdcacf98-62a1-47ae-92b9-e0320968ac1f"
```