rein-aharness/agent_harness/runner.py
tegwick 154e535695 feat: blueprint-aware policy resolve for multi-instance tenants (T07)
resolve_run_policy returns blueprint for persona prepare; add tenant
onboard helper script. Marks HARNESS-WP-0001-T07 done after binky
onboarding handoff.
2026-07-18 11:07:10 +02:00

222 lines
7 KiB
Python

"""One-task run orchestration.
Flow: lock target repo → resolve tool profile / budget from instance
manifest → snapshot HEAD → persona bundle → prompt → agentic session →
verify a new commit exists → kaizen metrics + hub progress event
(+ task close). The run *fails* if the session pushed anywhere or left
the repo dirty in a way it should not — the worker never pushes;
publishing is a separate, explicitly-granted lane.
"""
from __future__ import annotations
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from agent_harness import hub, metrics
from agent_harness.manifest import resolve_run_policy
from agent_harness.persona import load_persona_bundle
from agent_harness.profiles import UnknownToolProfileError, get_profile
from agent_harness.taskspec import TaskSpec
PROMPT_TEMPLATE = """\
You are an unattended executor session (agent persona below, if any).
Operating rules, non-negotiable:
- Work ONLY inside the current repository working directory.
- Green/Blue lane: file edits and local git add/commit only. Never push,
never touch the network, never run destructive commands.
- Tool profile for this run: {tool_profile} (lane={lane}).
- Bounded effort: complete the single task below, commit with a clear
message, then stop. If the task cannot be completed, commit nothing and
say why in your final output.
{persona}
## Task: {title}
{description}
"""
@dataclass
class RunResult:
ok: bool
committed: bool
head_before: str
head_after: str
persona_source: str
session_output: str
reason: str = ""
tool_profile: str = ""
budget_tokens: int | None = None
tokens_spent: int | None = None
execution_time_s: float = 0.0
def _git(repo: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(repo), *args],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError(f"git {' '.join(args)} failed: {result.stderr.strip()}")
return result.stdout.strip()
def run_task(
spec: TaskSpec,
adapter=None,
report_to_hub: bool = True,
write_metrics: bool = True,
) -> RunResult:
try:
profile_name, budget_tokens, lane, blueprint = resolve_run_policy(
spec.target_repo, spec.agent
)
profile = get_profile(profile_name)
except UnknownToolProfileError as exc:
return RunResult(
ok=False,
committed=False,
head_before="",
head_after="",
persona_source="none",
session_output="",
reason=f"refused: {exc}",
tool_profile="",
budget_tokens=None,
)
except Exception as exc:
return RunResult(
ok=False,
committed=False,
head_before="",
head_after="",
persona_source="none",
session_output="",
reason=f"manifest resolution failed: {exc}",
tool_profile="",
budget_tokens=None,
)
if adapter is None:
from agent_harness.adapter import AgenticClaudeCodeAdapter
adapter = AgenticClaudeCodeAdapter(
workdir=spec.target_repo,
tool_profile=profile,
)
head_before = _git(spec.target_repo, "rev-parse", "HEAD")
persona, persona_source = load_persona_bundle(blueprint, spec.target_repo)
prompt = PROMPT_TEMPLATE.format(
persona=persona or "(no persona bundle available for this run)",
title=spec.title,
description=spec.description,
tool_profile=profile.name,
lane=lane or profile.lane,
)
from llm_connect.models import BudgetTracker, RunConfig
budget_tracker = BudgetTracker(total=budget_tokens) if budget_tokens else None
config = RunConfig(
timeout_seconds=spec.timeout_seconds,
skip_if_exists=False,
budget_tracker=budget_tracker,
)
started = time.monotonic()
try:
response = adapter.execute_prompt(prompt, config)
session_output = response.content
session_ok = True
reason = ""
except Exception as exc: # adapter / budget failures must still be reported
session_output = ""
session_ok = False
reason = f"session failed: {exc}"
execution_time_s = time.monotonic() - started
head_after = _git(spec.target_repo, "rev-parse", "HEAD")
committed = head_after != head_before
ok = session_ok and committed
if session_ok and not committed:
reason = "session completed without committing"
tokens_spent = budget_tracker.spent if budget_tracker is not None else None
result = RunResult(
ok=ok,
committed=committed,
head_before=head_before,
head_after=head_after,
persona_source=persona_source,
session_output=session_output,
reason=reason,
tool_profile=profile.name,
budget_tokens=budget_tokens,
tokens_spent=tokens_spent,
execution_time_s=execution_time_s,
)
if write_metrics:
try:
metrics.record_execution(
spec.target_repo,
spec.agent,
success=ok,
execution_time_s=execution_time_s,
tokens=tokens_spent,
committed=committed,
head_after=head_after,
reason=reason or None,
metadata={
"task_title": spec.title,
"tool_profile": profile.name,
"labels": list(spec.labels),
"completion_event_type": spec.completion_event_type,
},
)
except OSError:
pass # metrics must not block run completion reporting
if report_to_hub:
detail = {
"repo": spec.target_repo.name,
"task_title": spec.title,
"agent": spec.agent,
"labels": spec.labels,
"persona_source": persona_source,
"committed": committed,
"head_after": head_after,
"ok": ok,
"reason": reason,
"tool_profile": profile.name,
"budget_tokens": budget_tokens,
"tokens_spent": tokens_spent,
"execution_time_s": round(execution_time_s, 3),
}
hub.post_progress_event(
summary=f"executor run: {spec.title} ({'ok' if ok else 'failed'})",
event_type=spec.completion_event_type,
detail=detail,
task_id=spec.hub_task_id,
)
if tokens_spent is not None or budget_tokens is not None:
hub.post_token_event(
repo=spec.target_repo.name,
tokens=tokens_spent or 0,
budget=budget_tokens,
agent=spec.agent,
ok=ok,
detail={"task_title": spec.title, "tool_profile": profile.name},
)
if ok and spec.hub_task_id:
hub.close_task(spec.hub_task_id)
return result