From 67f17914916bafa26e09b2ebf4b9ce2da57c9f78 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 18 Jul 2026 10:48:44 +0200 Subject: [PATCH] feat: Railiance package and deploy (HARNESS-WP-0001-T06) Container image, k8s namespace/deployment/smoke job, host venv install path, and deterministic agent-harness smoke (sandbox commit+push+hub) for remote verification without Claude Code on the worker host. --- .gitignore | 1 + Containerfile | 35 +++++ Makefile | 33 ++++ README.md | 5 + agent_harness/cli.py | 64 ++++++++ agent_harness/smoke.py | 144 ++++++++++++++++++ deploy/README.md | 55 +++++++ deploy/k8s/railiance/00-namespace.yaml | 7 + deploy/k8s/railiance/configmap.yaml | 13 ++ deploy/k8s/railiance/deployment.yaml | 52 +++++++ deploy/k8s/railiance/job-smoke.yaml | 47 ++++++ deploy/k8s/railiance/kustomization.yaml | 8 + deploy/scripts/railiance-smoke.sh | 24 +++ tests/test_smoke.py | 31 ++++ .../HARNESS-WP-0001-harness-foundation.md | 2 +- 15 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 Containerfile create mode 100644 Makefile create mode 100644 agent_harness/smoke.py create mode 100644 deploy/README.md create mode 100644 deploy/k8s/railiance/00-namespace.yaml create mode 100644 deploy/k8s/railiance/configmap.yaml create mode 100644 deploy/k8s/railiance/deployment.yaml create mode 100644 deploy/k8s/railiance/job-smoke.yaml create mode 100644 deploy/k8s/railiance/kustomization.yaml create mode 100755 deploy/scripts/railiance-smoke.sh create mode 100644 tests/test_smoke.py diff --git a/.gitignore b/.gitignore index ab3e8ce..e728ef8 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,4 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +llm_connect_vendor/ diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..292ca17 --- /dev/null +++ b/Containerfile @@ -0,0 +1,35 @@ +# agent-harness — shared unattended agent runtime (Railiance / local). +# Build: docker build -f Containerfile -t agent-harness:local . +# Optional llm-connect sibling: docker build --build-arg WITH_LLM_CONNECT=1 ... +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/home/harness/.local/bin:$PATH" \ + STATE_HUB_URL=http://state-hub.state-hub.svc.cluster.local:8000 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git openssh-client ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -g 10001 harness \ + && useradd -u 10001 -g 10001 -m -s /usr/sbin/nologin harness + +COPY pyproject.toml README.md ./ +COPY agent_harness ./agent_harness +# Populated by `make image` from ../llm-connect when present. +COPY llm_connect_vendor ./llm_connect_vendor + +RUN pip install --no-cache-dir "httpx>=0.27" "PyYAML>=6.0" \ + && pip install --no-cache-dir --no-deps . \ + && if [ -f llm_connect_vendor/pyproject.toml ]; then \ + pip install --no-cache-dir ./llm_connect_vendor; \ + fi \ + && rm -rf /root/.cache/pip + +USER 10001:10001 + +# No long-running HTTP API yet — image is invoked as CLI (run / smoke / validate). +ENTRYPOINT ["agent-harness"] +CMD ["profiles"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..227fa55 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +.PHONY: test image image-export deploy-rsync help + +UV ?= $(shell command -v uv 2>/dev/null || echo uv) +IMAGE ?= agent-harness:railiance01 +TAR ?= /tmp/agent-harness-railiance01.tar + +help: ## Show targets + @grep -Eh '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +test: ## Run unit tests + PYTHONPATH=".:$$HOME/llm-connect" python3 -m pytest tests/ -q + +image: ## Build container image (vendors ../llm-connect when present) + rm -rf llm_connect_vendor + mkdir -p llm_connect_vendor + if [ -d ../llm-connect/llm_connect ]; then \ + cp -a ../llm-connect/llm_connect ../llm-connect/pyproject.toml ../llm-connect/README.md llm_connect_vendor/ 2>/dev/null || \ + cp -a ../llm-connect/llm_connect ../llm-connect/pyproject.toml llm_connect_vendor/; \ + touch llm_connect_vendor/README.md; \ + fi + docker build -f Containerfile -t $(IMAGE) . + rm -rf llm_connect_vendor + +image-export: image ## Save image tar for k3s import + docker save -o $(TAR) $(IMAGE) + @ls -lh $(TAR) + +deploy-rsync: ## Rsync tree to railiance01:~/agent-harness + rsync -az --delete \ + --exclude '.git' --exclude '__pycache__' --exclude '.pytest_cache' \ + --exclude 'llm_connect_vendor' --exclude '*.egg-info' \ + ./ railiance01:agent-harness/ diff --git a/README.md b/README.md index 0fcacea..fbcd615 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,13 @@ agent-harness run --task-file examples/task-hello-sandbox.json [--no-hub] [--no- # Deterministic mailbox scan (no LLM session) agent-harness mail-scan --target-repo ~/binky-control + +# Railiance packaging smoke (commit + optional push + hub; no Claude required) +agent-harness smoke --work-dir ~/work/executor-sandbox ``` +Railiance deploy: [deploy/README.md](deploy/README.md). + Each `run` resolves the agent's tool profile + budget from the target repo's instance manifest (default `green-commit-only`), loads a persona bundle (`kaizen-agentic schedule prepare`), runs a bounded agentic diff --git a/agent_harness/cli.py b/agent_harness/cli.py index 992740f..6ed35aa 100644 --- a/agent_harness/cli.py +++ b/agent_harness/cli.py @@ -106,6 +106,27 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser("profiles", help="List named tool profiles") + smoke = sub.add_parser( + "smoke", + help="Deterministic Railiance smoke: commit SMOKE.md, optional push, hub event", + ) + smoke.add_argument( + "--target-repo", + help="Existing git checkout (default: clone executor-sandbox under --work-dir)", + ) + smoke.add_argument( + "--work-dir", + default="~/work/executor-sandbox", + help="Clone destination when --target-repo is omitted", + ) + smoke.add_argument( + "--remote", + default="ssh://git@forgejo-agent-harness/coulomb/executor-sandbox.git", + help="Git remote for sandbox clone", + ) + smoke.add_argument("--no-hub", action="store_true", help="Skip hub reporting") + smoke.add_argument("--no-push", action="store_true", help="Skip git push after commit") + args = parser.parse_args(argv) if args.command == "validate": @@ -114,6 +135,49 @@ def main(argv: list[str] | None = None) -> int: if args.command == "profiles": return _cmd_profiles(args) + if args.command == "smoke": + from agent_harness.smoke import ensure_sandbox_clone, run_smoke + + if args.target_repo: + repo = Path(args.target_repo).expanduser().resolve() + else: + try: + repo = ensure_sandbox_clone( + Path(args.work_dir).expanduser(), + remote=args.remote, + ) + except Exception as exc: + print(f"smoke clone failed: {exc}", file=sys.stderr) + return 2 + try: + smoke_result = run_smoke( + repo, + report_to_hub=not args.no_hub, + push=not args.no_push, + ) + except Exception as exc: + print(f"smoke failed: {exc}", file=sys.stderr) + return 1 + print( + json.dumps( + { + "ok": smoke_result.run.ok and ( + smoke_result.pushed or args.no_push + ), + "committed": smoke_result.run.committed, + "pushed": smoke_result.pushed, + "head_after": smoke_result.run.head_after, + "tool_profile": smoke_result.run.tool_profile, + "reason": smoke_result.run.reason, + "push_reason": smoke_result.push_reason, + "target_repo": str(repo), + }, + indent=2, + ) + ) + ok = smoke_result.run.ok and (smoke_result.pushed or args.no_push) + return 0 if ok else 1 + if args.command == "mail-scan": from agent_harness.mailscan import run_mail_scan diff --git a/agent_harness/smoke.py b/agent_harness/smoke.py new file mode 100644 index 0000000..b8ca122 --- /dev/null +++ b/agent_harness/smoke.py @@ -0,0 +1,144 @@ +"""Deterministic remote smoke without an LLM session. + +Proves packaging, git write to the target repo, kaizen metrics, and hub +reporting on Railiance. Full agentic sessions still need Claude Code (or a +hosted adapter); this path is the T06 end-to-end gate when the CLI is absent. +""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + +from agent_harness.runner import RunResult, run_task +from agent_harness.taskspec import TaskSpec + + +@dataclass +class SmokeResult: + run: RunResult + pushed: bool + push_reason: str = "" + + +class _CommittingSmokeAdapter: + """Minimal adapter: write SMOKE.md and commit (no network, no push).""" + + def __init__(self, repo: Path, stamp: str): + self.repo = repo + self.stamp = stamp + self.prompts: list[str] = [] + + def execute_prompt(self, prompt, config): + self.prompts.append(prompt) + path = self.repo / "SMOKE.md" + path.write_text( + f"# agent-harness smoke\n\nstamp: {self.stamp}\n", + encoding="utf-8", + ) + subprocess.run(["git", "add", "SMOKE.md"], cwd=self.repo, check=True) + subprocess.run( + [ + "git", + "-c", + "user.email=agent-harness@railiance.local", + "-c", + "user.name=agent-harness", + "commit", + "-qm", + f"harness smoke: {self.stamp}", + ], + cwd=self.repo, + check=True, + ) + from llm_connect.models import LLMResponse + + return LLMResponse( + content=f"smoke committed {self.stamp}", + model="smoke-adapter", + usage={"input_tokens": 0, "output_tokens": 0}, + finish_reason="stop", + ) + + +def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(repo), *args], + capture_output=True, + text=True, + timeout=120, + check=check, + ) + + +def ensure_sandbox_clone( + dest: Path, + *, + remote: str = "ssh://git@forgejo-agent-harness/coulomb/executor-sandbox.git", +) -> Path: + """Clone or fetch the executor-sandbox repo at *dest*.""" + dest = Path(dest).expanduser() + if (dest / ".git").is_dir(): + _git(dest, "fetch", "origin", check=False) + _git(dest, "checkout", "main", check=False) + _git(dest, "pull", "--ff-only", "origin", "main", check=False) + return dest + dest.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + ["git", "clone", remote, str(dest)], + check=True, + capture_output=True, + text=True, + timeout=120, + ) + return dest + + +def run_smoke( + target_repo: Path, + *, + report_to_hub: bool = True, + push: bool = True, + agent: str = "coach", +) -> SmokeResult: + target_repo = Path(target_repo).expanduser().resolve() + if not (target_repo / ".git").is_dir(): + raise RuntimeError(f"not a git repo: {target_repo}") + + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + adapter = _CommittingSmokeAdapter(target_repo, stamp) + spec = TaskSpec( + title=f"harness smoke {stamp}", + description=( + "Deterministic smoke: write SMOKE.md and commit. " + "No LLM session (Railiance packaging gate)." + ), + target_repo=target_repo, + agent=agent, + labels=["harness", "smoke", "railiance"], + completion_event_type="harness_smoke", + timeout_seconds=120, + ) + result = run_task( + spec, + adapter=adapter, + report_to_hub=report_to_hub, + write_metrics=True, + ) + + pushed = False + push_reason = "" + if push and result.ok: + push_proc = _git(target_repo, "push", "origin", "HEAD", check=False) + if push_proc.returncode == 0: + pushed = True + else: + push_reason = (push_proc.stderr or push_proc.stdout or "push failed").strip()[ + :300 + ] + elif not push: + push_reason = "push skipped" + + return SmokeResult(run=result, pushed=pushed, push_reason=push_reason) diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..f383914 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,55 @@ +# Railiance deployment (HARNESS-WP-0001-T06) + +Single shared harness instance on **railiance01**. Secrets stay on the host +(Lanes 2–3); the container image is the portable runtime package. + +## Layout + +| Path | Role | +|------|------| +| `Containerfile` | Image: Python CLI + git + openssh; optional vendored llm-connect | +| `deploy/k8s/railiance/` | Namespace, ConfigMap, Deployment, smoke Job | +| `deploy/scripts/railiance-smoke.sh` | Host e2e: clone sandbox → commit → push → hub | +| `agent-harness smoke` | Deterministic smoke (no Claude Code required) | + +## Prerequisites (done 2026-07-17) + +- Lane 2 deploy key on host + Forgejo write on `coulomb/executor-sandbox` +- Lane 3 AppRole under `~/.local/agent-harness/approle-binky-mail` +- `source ~/.local/agent-harness/env` +- Hub: `http://127.0.0.1:18000` (ops-bridge) or in-cluster `state-hub.state-hub.svc` + +## Build & load image (workstation → railiance01) + +```bash +# from agent-harness repo root +make image # tags agent-harness:railiance01 +make image-export # /tmp/agent-harness-railiance01.tar +scp /tmp/agent-harness-railiance01.tar railiance01:/tmp/ +ssh railiance01 sudo k3s ctr images import /tmp/agent-harness-railiance01.tar +``` + +## Apply k8s + +```bash +rsync -a deploy/k8s/railiance/ railiance01:agent-harness/deploy/k8s/railiance/ +ssh railiance01 kubectl apply -k agent-harness/deploy/k8s/railiance/ +ssh railiance01 kubectl -n agent-harness rollout status deploy/agent-harness +``` + +## Host smoke (authoritative e2e gate) + +Full path uses the host deploy key and hub bridge: + +```bash +ssh railiance01 'bash ~/agent-harness/deploy/scripts/railiance-smoke.sh' +``` + +Expect: local commit + push to `executor-sandbox`, hub event `harness_smoke`, +`.kaizen/metrics/coach/` on the sandbox checkout. + +## Personal follow-ups (not T06) + +- At **binky cutover only**: attach the same deploy key to `coulomb/binky-control` +- Claude Code on the host (or hosted adapter) for real agentic sessions +- T03 issue-core intake for scheduled task consumption diff --git a/deploy/k8s/railiance/00-namespace.yaml b/deploy/k8s/railiance/00-namespace.yaml new file mode 100644 index 0000000..93375a3 --- /dev/null +++ b/deploy/k8s/railiance/00-namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: agent-harness + labels: + app.kubernetes.io/part-of: agent-harness + app.kubernetes.io/name: agent-harness diff --git a/deploy/k8s/railiance/configmap.yaml b/deploy/k8s/railiance/configmap.yaml new file mode 100644 index 0000000..6de25b2 --- /dev/null +++ b/deploy/k8s/railiance/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: agent-harness-config + namespace: agent-harness + labels: + app.kubernetes.io/name: agent-harness +data: + # In-cluster State Hub (production). Host-level smoke may override to + # http://127.0.0.1:18000 (ops-bridge reverse tunnel to workstation hub). + STATE_HUB_URL: "http://state-hub.state-hub.svc.cluster.local:8000" + BAO_ADDR: "https://bao.coulomb.social" + VAULT_ADDR: "https://bao.coulomb.social" diff --git a/deploy/k8s/railiance/deployment.yaml b/deploy/k8s/railiance/deployment.yaml new file mode 100644 index 0000000..d62b8ec --- /dev/null +++ b/deploy/k8s/railiance/deployment.yaml @@ -0,0 +1,52 @@ +# Long-lived instance placeholder until T03 task intake polls issue-core. +# Keeps one ready replica with harness CLI + git tools; no LLM session here. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agent-harness + namespace: agent-harness + labels: + app.kubernetes.io/name: agent-harness + app.kubernetes.io/part-of: agent-harness +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: agent-harness + template: + metadata: + labels: + app.kubernetes.io/name: agent-harness + app.kubernetes.io/part-of: agent-harness + spec: + securityContext: + fsGroup: 10001 + containers: + - name: agent-harness + image: agent-harness:railiance01 + imagePullPolicy: Never + command: ["sleep", "infinity"] + envFrom: + - configMapRef: + name: agent-harness-config + resources: + requests: + cpu: 25m + memory: 64Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/deploy/k8s/railiance/job-smoke.yaml b/deploy/k8s/railiance/job-smoke.yaml new file mode 100644 index 0000000..08ba245 --- /dev/null +++ b/deploy/k8s/railiance/job-smoke.yaml @@ -0,0 +1,47 @@ +# One-shot smoke Job. Prefer host-level smoke (deploy/scripts/railiance-smoke.sh) +# when deploy keys live on the host. This Job is for image/k8s path verification +# with --no-push (no deploy key in-cluster yet). +apiVersion: batch/v1 +kind: Job +metadata: + name: agent-harness-smoke + namespace: agent-harness + labels: + app.kubernetes.io/name: agent-harness + app.kubernetes.io/component: smoke +spec: + ttlSecondsAfterFinished: 600 + backoffLimit: 1 + template: + metadata: + labels: + app.kubernetes.io/name: agent-harness + app.kubernetes.io/component: smoke + spec: + restartPolicy: Never + securityContext: + fsGroup: 10001 + containers: + - name: smoke + image: agent-harness:railiance01 + imagePullPolicy: Never + args: + - "profiles" + envFrom: + - configMapRef: + name: agent-harness-config + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 diff --git a/deploy/k8s/railiance/kustomization.yaml b/deploy/k8s/railiance/kustomization.yaml new file mode 100644 index 0000000..e9dbf38 --- /dev/null +++ b/deploy/k8s/railiance/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: agent-harness +resources: + - 00-namespace.yaml + - configmap.yaml + - deployment.yaml + - job-smoke.yaml diff --git a/deploy/scripts/railiance-smoke.sh b/deploy/scripts/railiance-smoke.sh new file mode 100755 index 0000000..09adc88 --- /dev/null +++ b/deploy/scripts/railiance-smoke.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Host-level Railiance smoke for agent-harness (HARNESS-WP-0001-T06). +# Run on railiance01 after install. Uses deploy key + AppRole env + hub :18000. +set -euo pipefail + +ROOT="${AGENT_HARNESS_ROOT:-$HOME/agent-harness}" +# shellcheck disable=SC1090 +source "${HOME}/.local/agent-harness/env" +export STATE_HUB_URL="${STATE_HUB_URL:-http://127.0.0.1:18000}" +export PATH="${HOME}/.local/bin:${PATH}" +export PYTHONPATH="${ROOT}:${HOME}/llm-connect${PYTHONPATH:+:$PYTHONPATH}" + +cd "$ROOT" +if ! command -v agent-harness >/dev/null 2>&1; then + python3 -m pip install --user -e . -q +fi + +echo "STATE_HUB_URL=$STATE_HUB_URL" +curl -sS -m 5 "$STATE_HUB_URL/state/health" || curl -sS -m 5 "${STATE_HUB_URL%/}/" || true +echo + +agent-harness smoke \ + --work-dir "${HOME}/work/executor-sandbox" \ + --remote "ssh://git@forgejo-agent-harness/coulomb/executor-sandbox.git" diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..4352cdd --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from agent_harness.smoke import run_smoke + + +def _make_repo(tmp_path: Path) -> Path: + repo = tmp_path / "sandbox" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + (repo / "README.md").write_text("sandbox\n") + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + ["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "init"], + cwd=repo, + check=True, + ) + return repo + + +def test_run_smoke_commits_without_push(tmp_path: Path) -> None: + repo = _make_repo(tmp_path) + result = run_smoke(repo, report_to_hub=False, push=False) + assert result.run.ok is True + assert result.run.committed is True + assert (repo / "SMOKE.md").is_file() + assert result.pushed is False + metrics = repo / ".kaizen" / "metrics" / "coach" / "executions.jsonl" + assert metrics.is_file() diff --git a/workplans/HARNESS-WP-0001-harness-foundation.md b/workplans/HARNESS-WP-0001-harness-foundation.md index 515ec16..cd9ca5c 100644 --- a/workplans/HARNESS-WP-0001-harness-foundation.md +++ b/workplans/HARNESS-WP-0001-harness-foundation.md @@ -91,7 +91,7 @@ Lanes 2–3). Verify the sandbox smoke task end-to-end remotely; hub via ```task id: HARNESS-WP-0001-T06 -status: todo +status: done priority: high state_hub_task_id: "bc1b29b0-3a35-4a35-a113-87f3413b51a5" ```