| 1 | #!/usr/bin/env python3
|
| 2 | """agent_loop_hosted_v1: one agent task loop against HOSTED Oak vs GitHub.
|
| 3 |
|
| 4 | Measures each step of a realistic agent loop -- acquire, branch, edit+commit,
|
| 5 | inspect, publish, review another branch, main advances (k=1, k=20) -> sync,
|
| 6 | merge to main, start the next task warm -- against oak.space and github.com.
|
| 7 |
|
| 8 | Per step it records wall time, commands (tool calls), output bytes, and on
|
| 9 | proxied trials the HTTP request count / bytes / serial request rounds:
|
| 10 |
|
| 11 | * Oak (``--mode proxied``): the clone points at a local plaintext->TLS
|
| 12 | forwarding proxy (``oakbench.httpcount``), so every API request to
|
| 13 | oak.space is counted with method/path/status/bytes/timing. Chunk traffic
|
| 14 | to ``cdn.oak.space`` goes through the same proxy as HTTPS CONNECT tunnels
|
| 15 | (``HTTPS_PROXY``): bytes and connection counts are measured, CDN request
|
| 16 | counts are not (null, ADR-0002). Auth comes from ``OAK_API_KEY`` read from
|
| 17 | ``~/.oak/credentials`` (never printed or logged).
|
| 18 | * Git (``--mode proxied``): ``http.proxy`` routes github.com through the same
|
| 19 | proxy as CONNECT tunnels (bytes, connections), and ``GIT_TRACE_CURL``
|
| 20 | (headers only; Authorization is redacted by Git and the trace file is
|
| 21 | parsed then deleted) supplies the request count.
|
| 22 | * ``--mode direct``: no instrumentation; timing only. Proxied timing is
|
| 23 | diagnostic (HTTP/1.1 through a local hop; direct Oak uses HTTP/2).
|
| 24 |
|
| 25 | Oracles are independent of the step's success output: remote heads via the
|
| 26 | Oak REST API / ``git ls-remote``; working-tree content via Git blob ids of the
|
| 27 | files on disk compared to the fixture's expected tree.
|
| 28 |
|
| 29 | Lane is excluded from devloop: it depends on a real network and hosted
|
| 30 | services (external variance). Hosted CI is out of scope (fixture carries no CI
|
| 31 | config). Setup (actor commits, fixture seeding) is never timed.
|
| 32 | """
|
| 33 |
|
| 34 | from __future__ import annotations
|
| 35 |
|
| 36 | import argparse
|
| 37 | import hashlib
|
| 38 | import json
|
| 39 | import os
|
| 40 | import platform
|
| 41 | import re
|
| 42 | import shutil
|
| 43 | import statistics
|
| 44 | import subprocess
|
| 45 | import sys
|
| 46 | import tempfile
|
| 47 | import time
|
| 48 | import urllib.request
|
| 49 | from dataclasses import dataclass, field
|
| 50 | from pathlib import Path
|
| 51 | from typing import Optional
|
| 52 |
|
| 53 | sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 54 |
|
| 55 | from oakbench.httpcount import HttpCountingProxy # noqa: E402
|
| 56 | from oakbench.runlock import measurement_lock # noqa: E402
|
| 57 |
|
| 58 | SCENARIO = "agent_loop_hosted_v1"
|
| 59 | SCENARIO_VERSION = 1
|
| 60 | OAK_HOST = "oak.space"
|
| 61 | GITHUB_HOST = "github.com"
|
| 62 |
|
| 63 | # Immutable operation vocabulary (ADR-0005).
|
| 64 | OPERATIONS = (
|
| 65 | "acquire.cold",
|
| 66 | "branch.create",
|
| 67 | "edit.commit",
|
| 68 | "inspect.status",
|
| 69 | "inspect.diff_main",
|
| 70 | "inspect.log",
|
| 71 | "publish.branch",
|
| 72 | "inspect.review_remote",
|
| 73 | "inspect.main_since_branch.k1",
|
| 74 | "sync.k1",
|
| 75 | "sync.k20",
|
| 76 | "merge.main",
|
| 77 | "next_task.warm",
|
| 78 | )
|
| 79 |
|
| 80 | # ---------------------------------------------------------------------------
|
| 81 | # Fixed task patch: 3 files, ~50 added lines, disjoint from actor files.
|
| 82 | # ---------------------------------------------------------------------------
|
| 83 |
|
| 84 |
|
| 85 | def task_patch(tag: str) -> dict:
|
| 86 | lines_a = [f"// agent task {tag} line {i}: widen the retry budget for step {i}" for i in range(20)]
|
| 87 | lines_b = [f"- agent task {tag} note {i}" for i in range(15)]
|
| 88 | lines_c = [f"agent task {tag} doc {i}" for i in range(15)]
|
| 89 | return {
|
| 90 | "cli/src/agent_task_note.rs": ("new", "\n".join(lines_a) + "\n"),
|
| 91 | "README.md": ("append", "\n" + "\n".join(lines_b) + "\n"),
|
| 92 | "docs/agent-task-notes.md": ("new", "\n".join(lines_c) + "\n"),
|
| 93 | }
|
| 94 |
|
| 95 |
|
| 96 | def apply_patch(root: Path, patch: dict) -> None:
|
| 97 | for rel, (kind, text) in patch.items():
|
| 98 | p = root / rel
|
| 99 | p.parent.mkdir(parents=True, exist_ok=True)
|
| 100 | if kind == "append":
|
| 101 | with p.open("a") as fh:
|
| 102 | fh.write(text)
|
| 103 | else:
|
| 104 | p.write_text(text)
|
| 105 |
|
| 106 |
|
| 107 | def actor_edit(root: Path, tag: str, i: int) -> str:
|
| 108 | rel = f"bench-history/actor-{tag}.txt"
|
| 109 | p = root / rel
|
| 110 | p.parent.mkdir(parents=True, exist_ok=True)
|
| 111 | with p.open("a") as fh:
|
| 112 | fh.write(f"actor {tag} commit {i}\n")
|
| 113 | return rel
|
| 114 |
|
| 115 |
|
| 116 | # ---------------------------------------------------------------------------
|
| 117 | # Oracles
|
| 118 | # ---------------------------------------------------------------------------
|
| 119 |
|
| 120 |
|
| 121 | def git_blob_id(data: bytes) -> str:
|
| 122 | return hashlib.sha1(b"blob %d\0" % len(data) + data).hexdigest()
|
| 123 |
|
| 124 |
|
| 125 | def workspace_digest(root: Path) -> dict:
|
| 126 | out = {}
|
| 127 | for dirpath, dirs, files in os.walk(root):
|
| 128 | rel = Path(dirpath).relative_to(root)
|
| 129 | if rel == Path("."):
|
| 130 | dirs[:] = [d for d in dirs if d not in (".git", ".oak")]
|
| 131 | for f in files:
|
| 132 | p = Path(dirpath) / f
|
| 133 | if rel == Path(".") and f == ".git": # worktree gitfile
|
| 134 | continue
|
| 135 | if p.is_symlink():
|
| 136 | continue
|
| 137 | out[str(rel / f) if str(rel) != "." else f] = git_blob_id(p.read_bytes())
|
| 138 | return out
|
| 139 |
|
| 140 |
|
| 141 | def fixture_tree(git_dir: Path, rev: str) -> dict:
|
| 142 | out = {}
|
| 143 | txt = subprocess.run(["git", "-C", str(git_dir), "ls-tree", "-r", rev], capture_output=True, text=True, check=True).stdout
|
| 144 | for line in txt.splitlines():
|
| 145 | meta, path = line.split("\t", 1)
|
| 146 | out[path] = meta.split()[2]
|
| 147 | return out
|
| 148 |
|
| 149 |
|
| 150 | TASK_PATHS = {"README.md", "cli/src/agent_task_note.rs", "docs/agent-task-notes.md"}
|
| 151 |
|
| 152 |
|
| 153 | def acquire_oracle(ws: Path, fixture_git: Path):
|
| 154 | """Every fixture path present with fixture content, except paths earlier
|
| 155 | loop trials legitimately change (task patch files, actor files)."""
|
| 156 | dig = workspace_digest(ws)
|
| 157 | exp = fixture_tree(fixture_git, "HEAD")
|
| 158 | missing = [p for p in exp if p not in dig]
|
| 159 | differ = [p for p in exp if p in dig and dig[p] != exp[p] and p not in TASK_PATHS]
|
| 160 | extra = [p for p in dig if p not in exp and p not in TASK_PATHS and not p.startswith("bench-history/actor-")]
|
| 161 | ok = not missing and not differ and not extra
|
| 162 | return ok, f"files={len(dig)} expected={len(exp)} missing={len(missing)} differ={len(differ)} extra={len(extra)}"
|
| 163 |
|
| 164 |
|
| 165 | def oak_token() -> str:
|
| 166 | for c in json.load(open(os.path.expanduser("~/.oak/credentials"))):
|
| 167 | if c.get("server") == f"https://{OAK_HOST}":
|
| 168 | return c["token"]
|
| 169 | raise SystemExit("no oak.space credential")
|
| 170 |
|
| 171 |
|
| 172 | def oak_api(path: str, token: str):
|
| 173 | req = urllib.request.Request(f"https://{OAK_HOST}{path}", headers={"Authorization": f"Bearer {token}", "User-Agent": "oakbench-oracle"})
|
| 174 | with urllib.request.urlopen(req, timeout=60) as r:
|
| 175 | return json.loads(r.read())
|
| 176 |
|
| 177 |
|
| 178 | def oak_remote_heads(repo: str, token: str) -> dict:
|
| 179 | d = oak_api(f"/api/{repo}/branches", token)
|
| 180 | return {b["name"]: b["head"] for b in d.get("branches", [])}
|
| 181 |
|
| 182 |
|
| 183 | def git_remote_heads(url: str, env: dict) -> dict:
|
| 184 | out = subprocess.run(["git", "ls-remote", url], capture_output=True, text=True, env=env).stdout
|
| 185 | heads = {}
|
| 186 | for line in out.splitlines():
|
| 187 | sha, ref = line.split("\t")
|
| 188 | if ref.startswith("refs/heads/"):
|
| 189 | heads[ref[len("refs/heads/"):]] = sha
|
| 190 | return heads
|
| 191 |
|
| 192 |
|
| 193 | # ---------------------------------------------------------------------------
|
| 194 | # Step execution
|
| 195 | # ---------------------------------------------------------------------------
|
| 196 |
|
| 197 |
|
| 198 | @dataclass
|
| 199 | class StepResult:
|
| 200 | op: str
|
| 201 | commands: list
|
| 202 | wall_s: float
|
| 203 | per_command_s: list
|
| 204 | returncodes: list
|
| 205 | output_bytes: int
|
| 206 | requests: Optional[int] = None
|
| 207 | request_bytes_up: Optional[int] = None
|
| 208 | request_bytes_down: Optional[int] = None
|
| 209 | api_connections: Optional[int] = None
|
| 210 | tunnel_connections: Optional[int] = None
|
| 211 | tunnel_bytes: Optional[int] = None
|
| 212 | tunnel_hosts: Optional[dict] = None
|
| 213 | serial_rounds: Optional[int] = None
|
| 214 | routes: Optional[dict] = None
|
| 215 | timeline: Optional[list] = None
|
| 216 | oracle_ok: Optional[bool] = None
|
| 217 | oracle_detail: str = ""
|
| 218 | stdout_tail: str = ""
|
| 219 | last_stdout: str = ""
|
| 220 |
|
| 221 |
|
| 222 | def serial_rounds(records) -> Optional[int]:
|
| 223 | """Number of request 'waves': a new wave starts when a request begins after
|
| 224 | every earlier request has completed (i.e., it had to wait a round trip)."""
|
| 225 | if not records:
|
| 226 | return 0
|
| 227 | recs = sorted(records, key=lambda r: r.start_ms)
|
| 228 | rounds, horizon = 0, -1.0
|
| 229 | for r in recs:
|
| 230 | end = r.end_ms if r.end_ms is not None else r.start_ms
|
| 231 | if r.start_ms >= horizon:
|
| 232 | rounds += 1
|
| 233 | horizon = max(horizon, end)
|
| 234 | return rounds
|
| 235 |
|
| 236 |
|
| 237 | class Subject:
|
| 238 | name = "?"
|
| 239 |
|
| 240 | def __init__(self, args, mode: str, workdir: Path):
|
| 241 | self.args = args
|
| 242 | self.mode = mode
|
| 243 | self.workdir = workdir
|
| 244 | self.proxy: Optional[HttpCountingProxy] = None
|
| 245 | self.token = oak_token() if self.name == "oak" else None
|
| 246 |
|
| 247 | # environment used for timed commands
|
| 248 | def env(self) -> dict:
|
| 249 | raise NotImplementedError
|
| 250 |
|
| 251 | def start(self):
|
| 252 | if self.mode == "proxied":
|
| 253 | self.proxy = HttpCountingProxy(self.upstream).start()
|
| 254 |
|
| 255 | def stop(self):
|
| 256 | if self.proxy:
|
| 257 | self.proxy.stop()
|
| 258 |
|
| 259 | def run_step(self, op: str, cmds: list, cwd: Path) -> StepResult:
|
| 260 | if self.proxy:
|
| 261 | self.proxy.reset()
|
| 262 | trace_files = []
|
| 263 | per, rcs, outb, tail, last_stdout = [], [], 0, "", ""
|
| 264 | gh_requests = 0
|
| 265 | t_all = time.monotonic()
|
| 266 | for cmd in cmds:
|
| 267 | env = self.env()
|
| 268 | if self.name == "git" and self.mode == "proxied":
|
| 269 | tf = tempfile.NamedTemporaryFile(prefix="gtrace-", delete=False, dir=self.workdir)
|
| 270 | tf.close()
|
| 271 | env["GIT_TRACE_CURL"] = tf.name
|
| 272 | env["GIT_TRACE_CURL_NO_DATA"] = "1"
|
| 273 | trace_files.append(tf.name)
|
| 274 | is_gh = Path(str(cmd[0])).name == "gh"
|
| 275 | if is_gh and self.proxy:
|
| 276 | # gh speaks HTTPS to api.github.com: count its requests from
|
| 277 | # GH_DEBUG=api (stderr; output_bytes are inflated in this mode,
|
| 278 | # use direct-mode rows for gh output bytes).
|
| 279 | env["HTTPS_PROXY"] = env["https_proxy"] = self.proxy.url
|
| 280 | env["GH_DEBUG"] = "api"
|
| 281 | t = time.monotonic()
|
| 282 | r = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True)
|
| 283 | per.append(time.monotonic() - t)
|
| 284 | if is_gh and self.proxy:
|
| 285 | gh_requests += sum(1 for ln in r.stderr.decode(errors="replace").splitlines() if ln.startswith("* Request to "))
|
| 286 | rcs.append(r.returncode)
|
| 287 | outb += len(r.stdout) + len(r.stderr)
|
| 288 | tail = (r.stdout[-400:] + b"|" + r.stderr[-600:]).decode(errors="replace")
|
| 289 | last_stdout = r.stdout.decode(errors="replace")
|
| 290 | if self.token:
|
| 291 | tail = tail.replace(self.token, "<redacted>")
|
| 292 | last_stdout = last_stdout.replace(self.token, "<redacted>")
|
| 293 | if r.returncode != 0:
|
| 294 | break
|
| 295 | wall = time.monotonic() - t_all
|
| 296 | res = StepResult(op, [" ".join(str(c) for c in cmd) for cmd in cmds], wall, per, rcs, outb, stdout_tail=tail, last_stdout=last_stdout)
|
| 297 | if self.proxy:
|
| 298 | time.sleep(0.05)
|
| 299 | s = self.proxy.snapshot()
|
| 300 | res.tunnel_connections = len(s.tunnels)
|
| 301 | res.tunnel_bytes = s.tunnel_bytes
|
| 302 | res.tunnel_hosts = s.tunnel_hosts()
|
| 303 | if self.name == "oak":
|
| 304 | res.requests = s.requests
|
| 305 | res.request_bytes_up = s.request_bytes
|
| 306 | res.request_bytes_down = s.response_bytes
|
| 307 | res.api_connections = s.connections - len(s.tunnels)
|
| 308 | res.serial_rounds = serial_rounds(s.records)
|
| 309 | res.routes = s.by_route()
|
| 310 | from oakbench.httpcount import route_shape
|
| 311 | recs = sorted(s.records, key=lambda r: r.start_ms)
|
| 312 | res.timeline = [[f"{r.method} {route_shape(r.path)}", r.status, round(r.start_ms, 1),
|
| 313 | None if r.ttfb_ms is None else round(r.ttfb_ms, 1),
|
| 314 | None if r.end_ms is None else round(r.end_ms - r.start_ms, 1), r.response_bytes]
|
| 315 | for r in (recs[:40] + (recs[-10:] if len(recs) > 50 else recs[40:]))]
|
| 316 | else:
|
| 317 | n, routes = 0, {}
|
| 318 | for tfn in trace_files:
|
| 319 | k, rt = parse_git_trace(tfn)
|
| 320 | n += k
|
| 321 | for key, v in rt.items():
|
| 322 | routes[key] = routes.get(key, 0) + v
|
| 323 | os.unlink(tfn)
|
| 324 | res.requests = n + gh_requests
|
| 325 | if gh_requests:
|
| 326 | routes["gh api (GH_DEBUG)"] = gh_requests
|
| 327 | res.routes = routes
|
| 328 | res.serial_rounds = n # git smart-HTTP requests are issued sequentially
|
| 329 | return res
|
| 330 |
|
| 331 |
|
| 332 | def parse_git_trace(path: str):
|
| 333 | """Count HTTP requests in a GIT_TRACE_CURL file.
|
| 334 |
|
| 335 | libcurl prints one ``=> Send header: METHOD /path HTTP/x`` request line per
|
| 336 | request for both HTTP/1.1 and HTTP/2; HTTP/2 additionally prints
|
| 337 | ``[HTTP/2] [n] [:method: ...]`` info lines for the same request, which must
|
| 338 | not be counted again (2026-09-27 rows were double counted this way).
|
| 339 | """
|
| 340 | n, routes = 0, {}
|
| 341 | with open(path, errors="replace") as fh:
|
| 342 | for line in fh:
|
| 343 | m = re.search(r"=> Send header: (GET|POST|PUT|HEAD|PATCH|DELETE) (\S+)", line)
|
| 344 | if m:
|
| 345 | n += 1
|
| 346 | key = f"{m.group(1)} {re.sub(r'^/[^/]+/[^/]+', '', m.group(2).split('?')[0])}"
|
| 347 | routes[key] = routes.get(key, 0) + 1
|
| 348 | return n, routes
|
| 349 |
|
| 350 |
|
| 351 | class OakSubject(Subject):
|
| 352 | name = "oak"
|
| 353 | upstream = OAK_HOST
|
| 354 |
|
| 355 | def env(self) -> dict:
|
| 356 | e = dict(os.environ, OAK_NO_UPDATE_CHECK="1", NO_COLOR="1")
|
| 357 | e.pop("OAK_REMOTE", None)
|
| 358 | if self.proxy:
|
| 359 | e["OAK_API_KEY"] = self.token
|
| 360 | e["OAK_REMOTE"] = self.proxy.url
|
| 361 | e["HTTPS_PROXY"] = e["https_proxy"] = self.proxy.url
|
| 362 | e["NO_PROXY"] = e["no_proxy"] = "127.0.0.1,localhost"
|
| 363 | return e
|
| 364 |
|
| 365 | def remote_arg(self) -> list:
|
| 366 | return ["-r", self.proxy.url] if self.proxy else []
|
| 367 |
|
| 368 |
|
| 369 | class GitSubject(Subject):
|
| 370 | name = "git"
|
| 371 | upstream = GITHUB_HOST
|
| 372 |
|
| 373 | def env(self) -> dict:
|
| 374 | e = dict(os.environ, GIT_TERMINAL_PROMPT="0", NO_COLOR="1")
|
| 375 | cfg = [("credential.helper", ""), ("credential.helper", f"!{shutil.which('gh')} auth git-credential"),
|
| 376 | ("advice.detachedHead", "false"), ("user.name", "bench-agent"), ("user.email", "[email protected]")]
|
| 377 | if self.proxy:
|
| 378 | cfg.append(("http.proxy", self.proxy.url))
|
| 379 | e["GIT_CONFIG_COUNT"] = str(len(cfg))
|
| 380 | for i, (k, v) in enumerate(cfg):
|
| 381 | e[f"GIT_CONFIG_KEY_{i}"] = k
|
| 382 | e[f"GIT_CONFIG_VALUE_{i}"] = v
|
| 383 | return e
|
| 384 |
|
| 385 |
|
| 386 | # ---------------------------------------------------------------------------
|
| 387 | # Loop
|
| 388 | # ---------------------------------------------------------------------------
|
| 389 |
|
| 390 |
|
| 391 | def loop_trial(subj: Subject, a, trial: int, runid: str, rows: list, actor_ws: Path, fixture_git: Path) -> None:
|
| 392 | ws = subj.workdir / f"{subj.name}-{subj.mode}-t{trial}"
|
| 393 | if ws.exists():
|
| 394 | shutil.rmtree(ws)
|
| 395 | tag = f"{runid}-{subj.mode[0]}{trial}"
|
| 396 | branch = f"alh-{tag}"
|
| 397 | patch = task_patch(tag)
|
| 398 | oak_bin = a.oak_bin
|
| 399 |
|
| 400 | def rec(res: StepResult, extra: Optional[dict] = None):
|
| 401 | row = {
|
| 402 | "lane": "agent_loop_hosted", "scenario": SCENARIO, "scenario_version": SCENARIO_VERSION,
|
| 403 | "subject": subj.name, "mode": subj.mode, "trial": trial, "run_id": runid,
|
| 404 | "operation": res.op, "variant": (extra or {}).get("variant"),
|
| 405 | "remote_transport": "network", "remote_server": OAK_HOST if subj.name == "oak" else GITHUB_HOST,
|
| 406 | "remote_repo": a.oak_repo if subj.name == "oak" else a.git_url,
|
| 407 | "wall_s": res.wall_s, "elapsed_ms": res.wall_s * 1000.0,
|
| 408 | "returncode": next((rc for rc in res.returncodes if rc != 0), 0), "per_command_s": res.per_command_s, "tool_calls": len(res.commands),
|
| 409 | "commands": res.commands, "returncodes": res.returncodes, "ok": all(rc == 0 for rc in res.returncodes),
|
| 410 | "output_bytes": res.output_bytes, "http_requests": res.requests,
|
| 411 | "http_request_bytes": res.request_bytes_up, "http_response_bytes": res.request_bytes_down,
|
| 412 | "api_connections": res.api_connections, "tunnel_connections": res.tunnel_connections,
|
| 413 | "tunnel_bytes": res.tunnel_bytes, "tunnel_hosts": res.tunnel_hosts, "serial_rounds": res.serial_rounds,
|
| 414 | "routes": res.routes, "timeline": res.timeline, "oracle_ok": res.oracle_ok, "oracle_detail": res.oracle_detail,
|
| 415 | "measurement_source": "httpcount_proxy+git_trace_curl" if subj.mode == "proxied" else "wall_clock_only",
|
| 416 | "tail": res.stdout_tail if not all(rc == 0 for rc in res.returncodes) else "",
|
| 417 | }
|
| 418 | if extra:
|
| 419 | row.update(extra)
|
| 420 | rows.append(row)
|
| 421 | flag = "ok" if row["ok"] else "FAIL"
|
| 422 | print(f" {subj.name:3} {subj.mode:7} t{trial} {res.op:30} {res.wall_s:7.3f}s req={res.requests} "
|
| 423 | f"up={res.request_bytes_up} down={res.request_bytes_down} tun={res.tunnel_bytes} oracle={res.oracle_ok} {flag}",
|
| 424 | file=sys.stderr, flush=True)
|
| 425 | if not row["ok"]:
|
| 426 | print(" tail:", row["tail"][-500:], file=sys.stderr)
|
| 427 | return row["ok"]
|
| 428 |
|
| 429 | is_oak = subj.name == "oak"
|
| 430 | ws.parent.mkdir(parents=True, exist_ok=True)
|
| 431 |
|
| 432 | # 1. acquire (cold for this repo on this machine: fresh directory, no shared object store)
|
| 433 | if is_oak:
|
| 434 | cmd = [[oak_bin, "clone", "--shallow", a.oak_repo, str(ws)] + subj.remote_arg()]
|
| 435 | else:
|
| 436 | cmd = [["git", "clone", "--filter=blob:none", "--single-branch", "--branch", "main", a.git_url, str(ws)]]
|
| 437 | res = subj.run_step("acquire.cold", cmd, subj.workdir)
|
| 438 | if all(rc == 0 for rc in res.returncodes):
|
| 439 | res.oracle_ok, res.oracle_detail = acquire_oracle(ws, fixture_git)
|
| 440 | if not rec(res, {"variant": "oak_clone_shallow" if is_oak else "git_clone_blobless"}):
|
| 441 | return
|
| 442 |
|
| 443 | # 2. branch
|
| 444 | cmd = [[oak_bin, "switch", "-c", branch]] if is_oak else [["git", "switch", "-c", branch]]
|
| 445 | res = subj.run_step("branch.create", cmd, ws)
|
| 446 | if not rec(res):
|
| 447 | return
|
| 448 |
|
| 449 | # 4. edit + commit (patch application is untimed; commit is timed)
|
| 450 | apply_patch(ws, patch)
|
| 451 | cmd = [[oak_bin, "commit", "--no-verify", "--quiet"]] if is_oak else [["git", "add", "-A"], ["git", "commit", "-q", "-m", f"agent task {tag}"]]
|
| 452 | res = subj.run_step("edit.commit", cmd, ws)
|
| 453 | if not rec(res):
|
| 454 | return
|
| 455 |
|
| 456 | # 3. inspect
|
| 457 | steps = {
|
| 458 | "inspect.status": ([[oak_bin, "status", "--json"]], [["git", "status", "--porcelain=v2", "--branch"]]),
|
| 459 | "inspect.diff_main": ([[oak_bin, "diff", "--branch", "--stat"]], [["git", "diff", "--stat", "origin/main...HEAD"]]),
|
| 460 | "inspect.log": ([[oak_bin, "log", "-n", "20", "--oneline"]], [["git", "log", "-n", "20", "--oneline"]]),
|
| 461 | }
|
| 462 | for op, (oc, gc) in steps.items():
|
| 463 | res = subj.run_step(op, oc if is_oak else gc, ws)
|
| 464 | rec(res)
|
| 465 |
|
| 466 | # 5. publish
|
| 467 | if is_oak:
|
| 468 | cmd = [[oak_bin, "push", "--json"]]
|
| 469 | else:
|
| 470 | cmd = [["git", "push", "-q", "-u", "origin", branch]]
|
| 471 | res = subj.run_step("publish.branch", cmd, ws)
|
| 472 | if all(rc == 0 for rc in res.returncodes):
|
| 473 | if is_oak:
|
| 474 | local = json.loads(subprocess.run([oak_bin, "status", "--json"], cwd=ws, capture_output=True, env=dict(os.environ, OAK_NO_UPDATE_CHECK="1")).stdout)["head"]
|
| 475 | remote = oak_remote_heads(a.oak_repo, subj.token).get(branch)
|
| 476 | else:
|
| 477 | local = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ws, capture_output=True, text=True).stdout.strip()
|
| 478 | remote = git_remote_heads(a.git_url, subj.env()).get(branch)
|
| 479 | res.oracle_ok = bool(local) and local == remote
|
| 480 | res.oracle_detail = f"local={local} remote={remote}"
|
| 481 | if not rec(res):
|
| 482 | return
|
| 483 |
|
| 484 | # review another agent's branch (pre-created fixture branch)
|
| 485 | if is_oak:
|
| 486 | cmd = [[oak_bin, "branch", "review", a.review_branch, "--remote", "--json"]]
|
| 487 | else:
|
| 488 | cmd = [["git", "fetch", "-q", "origin", a.review_branch], ["git", "diff", "--stat", "origin/main...FETCH_HEAD"]]
|
| 489 | res = subj.run_step("inspect.review_remote", cmd, ws)
|
| 490 | if all(rc == 0 for rc in res.returncodes):
|
| 491 | # the reviewed branch changes exactly review/r1..r3.txt
|
| 492 | seen = [f"review/r{i}.txt" in res.last_stdout for i in (1, 2, 3)]
|
| 493 | res.oracle_ok = all(seen)
|
| 494 | res.oracle_detail = f"review_files_listed={seen}"
|
| 495 | rec(res)
|
| 496 |
|
| 497 | # 6. main advances by k (actor, untimed) -> main_since_branch (k=1) -> sync
|
| 498 | for k in (1, 20):
|
| 499 | actor_land(subj, a, actor_ws, f"{tag}-k{k}", k)
|
| 500 | if k == 1:
|
| 501 | if is_oak:
|
| 502 | cmd = [[oak_bin, "log", "--remote", "--branch", "main", "-n", "20", "--json"]]
|
| 503 | else:
|
| 504 | cmd = [["git", "fetch", "-q", "origin", "main"], ["git", "log", "--oneline", "HEAD..origin/main"]]
|
| 505 | res = subj.run_step("inspect.main_since_branch.k1", cmd, ws)
|
| 506 | rec(res)
|
| 507 | cmd = [[oak_bin, "pull"]] if is_oak else [["git", "pull", "-q", "--rebase", "origin", "main"]]
|
| 508 | res = subj.run_step(f"sync.k{k}", cmd, ws)
|
| 509 | if all(rc == 0 for rc in res.returncodes):
|
| 510 | actor_file = ws / f"bench-history/actor-{tag}-k{k}.txt"
|
| 511 | ok_actor = actor_file.exists() and actor_file.read_text().count("\n") == k
|
| 512 | ok_patch = (ws / "docs/agent-task-notes.md").exists()
|
| 513 | res.oracle_ok = ok_actor and ok_patch
|
| 514 | res.oracle_detail = f"actor_lines_ok={ok_actor} patch_present={ok_patch}"
|
| 515 | if not rec(res, {"k": k}):
|
| 516 | return
|
| 517 |
|
| 518 | # 7. merge to main
|
| 519 | if is_oak:
|
| 520 | desc = ws.parent / f"desc-{tag}.txt"
|
| 521 | desc.write_text(f"agent task {tag}\n")
|
| 522 | subprocess.run([oak_bin, "desc", "--file", str(desc)], cwd=ws, env=subj.env(), capture_output=True)
|
| 523 | cmd = [[oak_bin, "merge", "--json"]]
|
| 524 | else:
|
| 525 | if a.git_merge == "pr_squash":
|
| 526 | gh = shutil.which("gh")
|
| 527 | cmd = [["git", "push", "-q", "-f", "origin", branch],
|
| 528 | [gh, "pr", "create", "--repo", a.git_slug, "--head", branch, "--base", "main", "--title", f"agent task {tag}", "--body", "bench"],
|
| 529 | [gh, "pr", "merge", branch, "--repo", a.git_slug, "--squash", "--delete-branch"]]
|
| 530 | else:
|
| 531 | cmd = [["git", "push", "-q", "origin", "HEAD:main"]]
|
| 532 | res = subj.run_step("merge.main", cmd, ws)
|
| 533 | if all(rc == 0 for rc in res.returncodes):
|
| 534 | if is_oak:
|
| 535 | heads = oak_remote_heads(a.oak_repo, subj.token)
|
| 536 | try:
|
| 537 | mj = json.loads(res.last_stdout)
|
| 538 | except ValueError:
|
| 539 | mj = {}
|
| 540 | res.oracle_ok = heads.get("main") is not None and (not mj.get("commit_hash") or heads.get("main") == mj.get("commit_hash"))
|
| 541 | res.oracle_detail = f"remote_main={heads.get('main')} merge_json_commit={mj.get('commit_hash')}"
|
| 542 | else:
|
| 543 | if a.git_merge == "pr_squash":
|
| 544 | genv = subj.env()
|
| 545 | subprocess.run(["git", "fetch", "-q", a.git_url, "main"], cwd=ws, env=genv, capture_output=True)
|
| 546 | tree_remote = subprocess.run(["git", "rev-parse", "FETCH_HEAD^{tree}"], cwd=ws, capture_output=True, text=True).stdout.strip()
|
| 547 | tree_local = subprocess.run(["git", "rev-parse", "HEAD^{tree}"], cwd=ws, capture_output=True, text=True).stdout.strip()
|
| 548 | res.oracle_ok = bool(tree_local) and tree_local == tree_remote
|
| 549 | res.oracle_detail = f"squash_tree_equals_branch_tree={res.oracle_ok}"
|
| 550 | else:
|
| 551 | local = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ws, capture_output=True, text=True).stdout.strip()
|
| 552 | remote = git_remote_heads(a.git_url, subj.env()).get("main")
|
| 553 | res.oracle_ok = local == remote
|
| 554 | res.oracle_detail = f"local={local} remote_main={remote}"
|
| 555 | if not rec(res, {"variant": "oak_merge_hosted_squash" if is_oak else ("git_gh_pr_squash" if a.git_merge == "pr_squash" else "git_push_ff_to_main")}):
|
| 556 | return
|
| 557 |
|
| 558 | # 8. next task, warm workspace: fresh main + new branch
|
| 559 | nb = f"{branch}-next"
|
| 560 | if is_oak:
|
| 561 | cmd = [[oak_bin, "switch", "-c", nb]]
|
| 562 | else:
|
| 563 | cmd = [["git", "fetch", "-q", "origin", "main"], ["git", "switch", "-q", "-c", nb, "origin/main"]]
|
| 564 | res = subj.run_step("next_task.warm", cmd, ws)
|
| 565 | if all(rc == 0 for rc in res.returncodes):
|
| 566 | dig = workspace_digest(ws)
|
| 567 | # the merged task patch must be present on the new task's base
|
| 568 | want = git_blob_id((ws / "docs/agent-task-notes.md").read_bytes()) if (ws / "docs/agent-task-notes.md").exists() else None
|
| 569 | k20 = ws / f"bench-history/actor-{tag}-k20.txt"
|
| 570 | res.oracle_ok = want is not None and k20.exists()
|
| 571 | res.oracle_detail = f"files={len(dig)} patch_present={want is not None} actor_k20_present={k20.exists()}"
|
| 572 | rec(res)
|
| 573 |
|
| 574 |
|
| 575 | ACQUIRE_VARIANTS = {
|
| 576 | # name: (subject, builder(a, subj, dest, warm) -> cmds)
|
| 577 | "oak_clone_shallow": ("oak", lambda a, subj, dest, warm: [[a.oak_bin, "clone", "--shallow", a.oak_repo, str(dest)] + subj.remote_arg()]),
|
| 578 | "oak_clone_full": ("oak", lambda a, subj, dest, warm: [[a.oak_bin, "clone", a.oak_repo, str(dest)] + subj.remote_arg()]),
|
| 579 | "git_clone_depth1": ("git", lambda a, subj, dest, warm: [["git", "clone", "-q", "--depth", "1", a.git_url, str(dest)]]),
|
| 580 | "git_clone_blobless": ("git", lambda a, subj, dest, warm: [["git", "clone", "-q", "--filter=blob:none", "--single-branch", "--branch", "main", a.git_url, str(dest)]]),
|
| 581 | "git_clone_full": ("git", lambda a, subj, dest, warm: [["git", "clone", "-q", a.git_url, str(dest)]]),
|
| 582 | "git_worktree_from_warm": ("git", lambda a, subj, dest, warm: [["git", "-C", str(warm), "fetch", "-q", "origin", "main"], ["git", "-C", str(warm), "worktree", "add", "-q", "-b", dest.name, str(dest), "origin/main"]]),
|
| 583 | }
|
| 584 |
|
| 585 |
|
| 586 | def acquire_trials(a, runid: str, rows: list) -> None:
|
| 587 | """acquire.cold variants, each into a fresh directory; oracle = fixture tree."""
|
| 588 | for trial in range(a.runs):
|
| 589 | for mode in a.modes.split(","):
|
| 590 | for vname, (sname, build) in ACQUIRE_VARIANTS.items():
|
| 591 | if sname not in a.subjects.split(",") or (a.variants and vname not in a.variants.split(",")):
|
| 592 | continue
|
| 593 | subj = OakSubject(a, mode, a.workdir) if sname == "oak" else GitSubject(a, mode, a.workdir)
|
| 594 | subj.start()
|
| 595 | try:
|
| 596 | dest = a.workdir / f"acq-{vname}-{mode}-{runid}-{trial}"
|
| 597 | warm = a.workdir / "git-warm"
|
| 598 | if vname == "git_worktree_from_warm" and not warm.exists():
|
| 599 | subprocess.run(["git", "clone", "-q", "--filter=blob:none", a.git_url, str(warm)], env=GitSubject(a, "direct", a.workdir).env(), check=True)
|
| 600 | res = subj.run_step("acquire.cold", build(a, subj, dest, warm), a.workdir)
|
| 601 | if all(rc == 0 for rc in res.returncodes):
|
| 602 | res.oracle_ok, res.oracle_detail = acquire_oracle(dest, a.fixture_git)
|
| 603 | rows.append(row_for(subj, a, trial, runid, res, {"variant": vname, "warm_source": vname == "git_worktree_from_warm"}))
|
| 604 | _print_row(rows[-1])
|
| 605 | finally:
|
| 606 | subj.stop()
|
| 607 | if vname == "git_worktree_from_warm":
|
| 608 | subprocess.run(["git", "-C", str(a.workdir / "git-warm"), "worktree", "remove", "--force", str(dest)], capture_output=True)
|
| 609 | subprocess.run(["git", "-C", str(a.workdir / "git-warm"), "branch", "-q", "-D", dest.name], capture_output=True)
|
| 610 | elif dest.exists():
|
| 611 | shutil.rmtree(dest, ignore_errors=True)
|
| 612 |
|
| 613 |
|
| 614 | def review_trials(a, runid: str, rows: list) -> None:
|
| 615 | """inspect.review_remote only, from existing warm workspaces (--review-oak-ws /
|
| 616 | --review-git-ws), against a freshly created review branch. Proxied Oak runs
|
| 617 | rebind the proxy to the port stored as the workspace's remote."""
|
| 618 | for trial in range(a.runs):
|
| 619 | for mode in a.modes.split(","):
|
| 620 | for sname in a.subjects.split(","):
|
| 621 | ws = Path(a.review_oak_ws if sname == "oak" else a.review_git_ws)
|
| 622 | subj = OakSubject(a, mode, a.workdir) if sname == "oak" else GitSubject(a, mode, a.workdir)
|
| 623 | if mode == "proxied":
|
| 624 | port = 0
|
| 625 | if sname == "oak":
|
| 626 | info = json.loads(subprocess.run([a.oak_bin, "status", "--json"], cwd=ws, capture_output=True,
|
| 627 | env=dict(os.environ, OAK_NO_UPDATE_CHECK="1")).stdout)
|
| 628 | m = re.match(r"http://127\.0\.0\.1:(\d+)", info.get("remote_url") or "")
|
| 629 | if not m:
|
| 630 | print("skip: oak review workspace is not proxied", file=sys.stderr)
|
| 631 | continue
|
| 632 | port = int(m.group(1))
|
| 633 | subj.proxy = HttpCountingProxy(subj.upstream, listen_port=port).start()
|
| 634 | try:
|
| 635 | if sname == "oak":
|
| 636 | cmd = [[a.oak_bin, "branch", "review", a.review_branch, "--remote", "--json"]]
|
| 637 | else:
|
| 638 | cmd = [["git", "fetch", "-q", "origin", a.review_branch], ["git", "diff", "--stat", "origin/main...FETCH_HEAD"]]
|
| 639 | res = subj.run_step("inspect.review_remote", cmd, ws)
|
| 640 | if all(rc == 0 for rc in res.returncodes):
|
| 641 | seen = [f"/r{i}.txt" in res.last_stdout for i in (1, 2, 3)]
|
| 642 | res.oracle_ok = all(seen)
|
| 643 | res.oracle_detail = f"review_files_listed={seen}"
|
| 644 | rows.append(row_for(subj, a, trial, runid, res, {"variant": "fresh_review_branch"}))
|
| 645 | _print_row(rows[-1])
|
| 646 | finally:
|
| 647 | subj.stop()
|
| 648 |
|
| 649 |
|
| 650 | def row_for(subj, a, trial, runid, res, extra=None) -> dict:
|
| 651 | row = {
|
| 652 | "lane": "agent_loop_hosted", "scenario": SCENARIO, "scenario_version": SCENARIO_VERSION,
|
| 653 | "subject": subj.name, "mode": subj.mode, "trial": trial, "run_id": runid,
|
| 654 | "operation": res.op, "variant": None,
|
| 655 | "remote_transport": "network", "remote_server": OAK_HOST if subj.name == "oak" else GITHUB_HOST,
|
| 656 | "remote_repo": a.oak_repo if subj.name == "oak" else a.git_url,
|
| 657 | "wall_s": res.wall_s, "elapsed_ms": res.wall_s * 1000.0,
|
| 658 | "returncode": next((rc for rc in res.returncodes if rc != 0), 0), "per_command_s": res.per_command_s, "tool_calls": len(res.commands),
|
| 659 | "commands": res.commands, "returncodes": res.returncodes, "ok": all(rc == 0 for rc in res.returncodes),
|
| 660 | "output_bytes": res.output_bytes, "http_requests": res.requests,
|
| 661 | "http_request_bytes": res.request_bytes_up, "http_response_bytes": res.request_bytes_down,
|
| 662 | "api_connections": res.api_connections, "tunnel_connections": res.tunnel_connections,
|
| 663 | "tunnel_bytes": res.tunnel_bytes, "tunnel_hosts": res.tunnel_hosts, "serial_rounds": res.serial_rounds,
|
| 664 | "routes": res.routes, "timeline": res.timeline, "oracle_ok": res.oracle_ok, "oracle_detail": res.oracle_detail,
|
| 665 | "measurement_source": "httpcount_proxy+git_trace_curl" if subj.mode == "proxied" else "wall_clock_only",
|
| 666 | "tail": res.stdout_tail if not all(rc == 0 for rc in res.returncodes) else "",
|
| 667 | }
|
| 668 | if extra:
|
| 669 | row.update(extra)
|
| 670 | return row
|
| 671 |
|
| 672 |
|
| 673 | def _print_row(row: dict) -> None:
|
| 674 | print(f" {row['subject']:3} {row['mode']:7} t{row['trial']} {row['operation']:30} {str(row.get('variant') or ''):24} {row['wall_s']:7.3f}s "
|
| 675 | f"req={row['http_requests']} down={row['http_response_bytes']} tun={row['tunnel_bytes']} oracle={row['oracle_ok']} {'ok' if row['ok'] else 'FAIL'}",
|
| 676 | file=sys.stderr, flush=True)
|
| 677 | if not row["ok"]:
|
| 678 | print(" tail:", row["tail"][-500:], file=sys.stderr)
|
| 679 |
|
| 680 |
|
| 681 | def actor_land(subj: Subject, a, actor_ws: Path, tag: str, k: int) -> None:
|
| 682 | """Another actor lands k commits on main touching only bench-history/actor-*. Untimed."""
|
| 683 | env = dict(os.environ, OAK_NO_UPDATE_CHECK="1")
|
| 684 | if subj.name == "oak":
|
| 685 | for i in range(k):
|
| 686 | subprocess.run([a.oak_bin, "switch", "-c", f"actor-{tag}-{i}"], cwd=actor_ws, env=env, capture_output=True, check=True)
|
| 687 | actor_edit(actor_ws, tag, i)
|
| 688 | subprocess.run([a.oak_bin, "commit", "--no-verify", "--quiet"], cwd=actor_ws, env=env, capture_output=True, check=True)
|
| 689 | subprocess.run([a.oak_bin, "desc", f"actor {tag} {i}"], cwd=actor_ws, env=env, capture_output=True, check=True)
|
| 690 | r = subprocess.run([a.oak_bin, "merge", "--json"], cwd=actor_ws, env=env, capture_output=True)
|
| 691 | if r.returncode != 0:
|
| 692 | raise SystemExit(f"actor merge failed: {r.stdout[-400:]!r} {r.stderr[-400:]!r}")
|
| 693 | else:
|
| 694 | genv = GitSubject.env(subj) if isinstance(subj, GitSubject) else env
|
| 695 | genv = dict(genv)
|
| 696 | genv.pop("GIT_TRACE_CURL", None)
|
| 697 | # actor uses no proxy
|
| 698 | cfgn = int(genv.get("GIT_CONFIG_COUNT", "0"))
|
| 699 | for i in range(cfgn):
|
| 700 | if genv.get(f"GIT_CONFIG_KEY_{i}") == "http.proxy":
|
| 701 | genv[f"GIT_CONFIG_VALUE_{i}"] = ""
|
| 702 | subprocess.run(["git", "pull", "-q", "--rebase", "origin", "main"], cwd=actor_ws, env=genv, capture_output=True, check=True)
|
| 703 | for i in range(k):
|
| 704 | actor_edit(actor_ws, tag, i)
|
| 705 | subprocess.run(["git", "add", "-A"], cwd=actor_ws, env=genv, check=True)
|
| 706 | subprocess.run(["git", "commit", "-q", "-m", f"actor {tag} {i}"], cwd=actor_ws, env=genv, check=True)
|
| 707 | subprocess.run(["git", "push", "-q", "origin", "HEAD:main"], cwd=actor_ws, env=genv, capture_output=True, check=True)
|
| 708 |
|
| 709 |
|
| 710 | def summarize(rows: list) -> str:
|
| 711 | groups: dict = {}
|
| 712 | for r in rows:
|
| 713 | groups.setdefault((r["operation"], r["subject"], r.get("mode", "?"), r.get("variant") or ""), []).append(r)
|
| 714 | lines = ["| operation | subject | mode | variant | n | ok | median s | min-max s | req (median) | bytes down (median) | tunnel bytes | serial rounds | tool calls | out bytes |",
|
| 715 | "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
|
| 716 |
|
| 717 | def med(vals):
|
| 718 | vals = [v for v in vals if v is not None]
|
| 719 | return statistics.median(vals) if vals else None
|
| 720 |
|
| 721 | for (op, s, m, v), rs in sorted(groups.items(), key=lambda kv: (OPERATIONS.index(kv[0][0]) if kv[0][0] in OPERATIONS else 99, kv[0][1], kv[0][2], kv[0][3])):
|
| 722 | ok = [r for r in rs if r["ok"]]
|
| 723 | w = [r["wall_s"] for r in ok if r.get("wall_s") is not None]
|
| 724 | lines.append(
|
| 725 | f"| {op} | {s} | {m} | {v} | {len(rs)} | {len(ok)} | "
|
| 726 | f"{statistics.median(w):.3f} | {min(w):.3f}-{max(w):.3f} | " if w else f"| {op} | {s} | {m} | {v} | {len(rs)} | 0 | - | - | "
|
| 727 | )
|
| 728 | lines[-1] += f"{med([r['http_requests'] for r in ok])} | {med([r['http_response_bytes'] for r in ok])} | {med([r['tunnel_bytes'] for r in ok])} | {med([r['serial_rounds'] for r in ok])} | {med([r['tool_calls'] for r in ok])} | {med([r['output_bytes'] for r in ok])} |"
|
| 729 | return "\n".join(lines)
|
| 730 |
|
| 731 |
|
| 732 | def main(argv=None) -> int:
|
| 733 | with measurement_lock("agent_loop_hosted") as lock_info:
|
| 734 | print(f"measurement lock: {'held' if lock_info.enabled else 'DISABLED'} (waited {lock_info.wait_ms or 0:.0f} ms)", file=sys.stderr)
|
| 735 | return _main(argv)
|
| 736 |
|
| 737 |
|
| 738 | def _main(argv=None) -> int:
|
| 739 | ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
| 740 | ap.add_argument("--subjects", default="oak,git")
|
| 741 | ap.add_argument("--modes", default="proxied,direct")
|
| 742 | ap.add_argument("--runs", type=int, default=3)
|
| 743 | ap.add_argument("--oak-bin", required=True)
|
| 744 | ap.add_argument("--oak-repo", required=True, help="ORG/REPO on oak.space (write access required)")
|
| 745 | ap.add_argument("--git-url", required=True, help="https URL of the GitHub fixture repo")
|
| 746 | ap.add_argument("--fixture-git", required=True, type=Path, help="local Git fixture (expected tree oracle)")
|
| 747 | ap.add_argument("--review-branch", required=True)
|
| 748 | ap.add_argument("--workdir", required=True, type=Path)
|
| 749 | ap.add_argument("--results", required=True, type=Path)
|
| 750 | ap.add_argument("--part", choices=("loop", "acquire", "review"), default="loop")
|
| 751 | ap.add_argument("--review-oak-ws", help="--part review: existing Oak workspace")
|
| 752 | ap.add_argument("--review-git-ws", help="--part review: existing Git workspace")
|
| 753 | ap.add_argument("--variants", default="", help="--part acquire: comma list of variant names (default all)")
|
| 754 | ap.add_argument("--git-merge", choices=("push_ff", "pr_squash"), default="push_ff",
|
| 755 | help="Git merge.main path: fast-forward push to main, or gh pr create + gh pr merge --squash")
|
| 756 | a = ap.parse_args(argv)
|
| 757 | a.oak_bin = str(Path(a.oak_bin).resolve())
|
| 758 | a.git_slug = re.sub(r"^https://github\.com/|\.git$", "", a.git_url)
|
| 759 | runid = time.strftime("%H%M%S", time.gmtime())
|
| 760 | a.workdir.mkdir(parents=True, exist_ok=True)
|
| 761 | a.results.mkdir(parents=True, exist_ok=True)
|
| 762 | out = a.results / f"agent_loop_hosted-{runid}.jsonl"
|
| 763 | rows: list = []
|
| 764 | env_info = {"load_avg": os.getloadavg(), "host": platform.node(), "oak_bin_sha256": hashlib.sha256(Path(a.oak_bin).read_bytes()).hexdigest(),
|
| 765 | "git_version": subprocess.run(["git", "--version"], capture_output=True, text=True).stdout.strip()}
|
| 766 | print("env:", json.dumps(env_info), file=sys.stderr)
|
| 767 | if a.part in ("acquire", "review"):
|
| 768 | (acquire_trials if a.part == "acquire" else review_trials)(a, runid, rows)
|
| 769 | with out.open("w") as fh:
|
| 770 | for r in rows:
|
| 771 | r.setdefault("env", env_info)
|
| 772 | fh.write(json.dumps(r) + "\n")
|
| 773 | print(summarize(rows))
|
| 774 | return 0
|
| 775 | # actor workspaces (warm, persistent within this run)
|
| 776 | actors = {}
|
| 777 | for s in a.subjects.split(","):
|
| 778 | aw = a.workdir / f"actor-{s}"
|
| 779 | if not aw.exists():
|
| 780 | if s == "oak":
|
| 781 | subprocess.run([a.oak_bin, "clone", "--shallow", a.oak_repo, str(aw)], env=dict(os.environ, OAK_NO_UPDATE_CHECK="1"), check=True, capture_output=True)
|
| 782 | else:
|
| 783 | g = GitSubject(a, "direct", a.workdir)
|
| 784 | subprocess.run(["git", "clone", "-q", a.git_url, str(aw)], env=g.env(), check=True)
|
| 785 | actors[s] = aw
|
| 786 | for trial in range(a.runs):
|
| 787 | for mode in a.modes.split(","):
|
| 788 | order = a.subjects.split(",")
|
| 789 | if trial % 2:
|
| 790 | order = list(reversed(order))
|
| 791 | for s in order:
|
| 792 | subj = OakSubject(a, mode, a.workdir) if s == "oak" else GitSubject(a, mode, a.workdir)
|
| 793 | subj.start()
|
| 794 | try:
|
| 795 | loop_trial(subj, a, trial, runid, rows, actors[s], a.fixture_git)
|
| 796 | except SystemExit as e:
|
| 797 | print("trial aborted:", e, file=sys.stderr)
|
| 798 | rows.append({"lane": "agent_loop_hosted", "scenario": SCENARIO, "subject": s, "mode": mode, "trial": trial, "operation": "trial.aborted",
|
| 799 | "ok": False, "elapsed_ms": 0.0, "returncode": 1, "measurement_source": "wall_clock_only", "tail": str(e)[-500:]})
|
| 800 | finally:
|
| 801 | subj.stop()
|
| 802 | with out.open("w") as fh:
|
| 803 | for r in rows:
|
| 804 | r.setdefault("env", env_info)
|
| 805 | fh.write(json.dumps(r) + "\n")
|
| 806 | summary = summarize(rows)
|
| 807 | (a.results / f"agent_loop_hosted-{runid}.summary.md").write_text(summary + "\n")
|
| 808 | print(summary)
|
| 809 | print(f"rows: {out}", file=sys.stderr)
|
| 810 | return 0
|
| 811 |
|
| 812 |
|
| 813 | if __name__ == "__main__":
|
| 814 | raise SystemExit(main())
|