Viewing files at commit d3edd8c5e361Commit detailsView latest

Browse files
scripts/agent_loop_hosted.py 814 lines · 39.0 KB · python
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())