Viewing files at commit dc75e12cb299Commit detailsView latest

Browse files
scripts/task_loop.py 1264 lines · 48.8 KB · python
1
#!/usr/bin/env python3
2
"""The full task loop, end to end — the product's signature motion.
3
4
oak:  mount -> edit -> commit -> push -> desc -> end, then REMOUNT for a
5
      follow-up task (the warm-cache story) and repeat edit/commit/push/end.
6
git:  worktree add -> branch edit -> add/commit -> push -> worktree remove,
7
      then a second worktree for the follow-up. The task description lives in
8
      the commit message git already paid for; oak pays a separate `desc`
9
      call. That asymmetry is the measurement, not a harness gap
10
      (docs/command-semantics.md): this is agent-default-track, whole-task
11
      evidence, never a step-by-step equivalence claim.
12
13
Transport honesty: oak rows always cross the network to a real oak server.
14
git runs against GitHub (--git-remote github, transport-comparable) or a
15
local bare repo (--git-remote local, the core-equivalent track). Rows carry
16
remote_transport/remote_server; latency comparisons across transports are
17
forbidden — cross-subject evidence at unlike transports is tool calls and
18
tokens only.
19
20
Setup:
21
  OAK_BENCH_SYNC_REPO=oak/bench-sync-tmp \\
22
  [email protected]:oakdotspace/bench-task-loop-tmp.git \\
23
  python3 scripts/task_loop.py --runs 3
24
25
Unconfigured remotes emit skip rows (returncode 77), never silent absence.
26
"""
27
28
from __future__ import annotations
29
30
import argparse
31
import json
32
import platform
33
import random
34
import re
35
import shutil
36
import socket
37
import subprocess
38
import tempfile
39
import threading
40
import time
41
from datetime import datetime, timezone
42
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
43
from pathlib import Path
44
from typing import Any
45
46
from oakbench import remotes as oakbench_remotes
47
from oakbench import tokens as oakbench_tokens
48
from oakbench.diskprobe import bounded_tree_usage
49
from oakbench.environment import ENV_ISOLATION_VERSION, base_env as oakbench_base_env
50
from oakbench.environment import command_display
51
from oakbench.execution import run_timed
52
from oakbench.results import ResultsStore
53
from oakbench.rows import row_returncode
54
from oakbench.runlock import measurement_lock
55
from oakbench.subjects import (
56
    DEFAULT_OAK_REPO,
57
    Subject,
58
    load_subjects,
59
    source_metadata,
60
    subject_details,
61
    subject_versions,
62
)
63
64
ROOT = Path(__file__).resolve().parents[1]
65
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-task-loop"
66
SKIP_RETURNCODE = 77
67
VIRTUAL_BRANCH_RE = re.compile(r"virtual branch ([^\s)]+)")
68
PHASES = ("task", "followup")
69
TASK_LOOP_SCENARIO = "task_loop"
70
AGENT_TASK_SCENARIO = "agent_task_baseline_v1"
71
INTERRUPTED_PUBLICATION_SCENARIO = "interrupted_publication_after_request_v1"
72
73
74
def base_env() -> dict[str, str]:
75
    return oakbench_base_env(
76
        author_name="Oak Task Loop",
77
        author_email="[email protected]",
78
        oak_author="oak-task-loop",
79
    )
80
81
82
def parse_args() -> argparse.Namespace:
83
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
84
    parser.add_argument("--subjects", help="Comma-separated subject names from config/subjects.toml")
85
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
86
    parser.add_argument("--git-bin", type=Path)
87
    parser.add_argument("--oak-installed-bin", type=Path)
88
    parser.add_argument("--oak-local-bin", type=Path)
89
    parser.add_argument("--runs", type=int, default=3)
90
    parser.add_argument(
91
        "--scenarios",
92
        default=TASK_LOOP_SCENARIO,
93
        help=(
94
            "Comma-separated scenarios: task_loop, agent_task_baseline_v1, and/or "
95
            "interrupted_publication_after_request_v1. The interruption "
96
            "scenario is a loopback-only Oak fixture and never contacts a remote service."
97
        ),
98
    )
99
    parser.add_argument(
100
        "--git-remote",
101
        choices=("github", "local"),
102
        default="github",
103
        help=(
104
            "github: agent-default track, real push to GIT_BENCH_REMOTE "
105
            "(transport-comparable to oak). local: core-equivalent track, "
106
            "local bare remote (no network in git's loop)."
107
        ),
108
    )
109
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
110
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "task-loop")
111
    parser.add_argument("--oak-repo", type=Path, default=DEFAULT_OAK_REPO)
112
    parser.add_argument("--admitted-output-chars", type=int, default=20_000)
113
    parser.add_argument("--keep-workdirs", action="store_true")
114
    parser.add_argument(
115
        "--randomize-subject-order",
116
        action="store_true",
117
        help="Shuffle subject order independently in each run using --random-seed.",
118
    )
119
    parser.add_argument("--random-seed", type=int, default=20260908)
120
    return parser.parse_args()
121
122
123
def run_untimed(command: list[str], cwd: Path | None, timeout: float = 300.0) -> subprocess.CompletedProcess[str]:
124
    return subprocess.run(
125
        command,
126
        cwd=cwd,
127
        env=base_env(),
128
        text=True,
129
        stdout=subprocess.PIPE,
130
        stderr=subprocess.PIPE,
131
        timeout=timeout,
132
        check=False,
133
    )
134
135
136
def step_row(
137
    subject: Subject,
138
    scenario: str,
139
    operation: str,
140
    command: list[str],
141
    cwd: Path,
142
    run_index: int,
143
    metadata: dict[str, Any],
144
    admitted_output_chars: int,
145
    *,
146
    kind: str = "vcs",
147
    expected_returncodes: tuple[int, ...] = (0,),
148
    preserve_stdout: bool = False,
149
    env: dict[str, str] | None = None,
150
) -> dict[str, Any]:
151
    capture = run_timed(command, cwd, env or base_env(), admitted_output_chars)
152
    expected = capture.returncode in expected_returncodes
153
    is_vcs = 1 if kind == "vcs" else 0
154
    row = {
155
        **metadata,
156
        "subject": subject.name,
157
        "subject_kind": subject.kind,
158
        "subject_label": subject.label,
159
        "scenario": scenario,
160
        "run": run_index,
161
        "operation": operation,
162
        "step_kind": kind,
163
        "elapsed_ms": round(capture.elapsed_ms, 3),
164
        "returncode": 0 if expected else (capture.returncode or 1),
165
        "process_returncode": capture.returncode,
166
        "expected_returncodes": list(expected_returncodes),
167
        "command": command,
168
        "tool_call_count": 1,
169
        "terminal_tool_call_count": 1,
170
        "vcs_tool_call_count": is_vcs,
171
        **oakbench_tokens.interaction_token_fields(
172
            command_display(command),
173
            capture.stdout_text,
174
            capture.stderr_text,
175
            capture.stdout_bytes,
176
            capture.stderr_bytes,
177
            capture.stdout_truncated,
178
            capture.stderr_truncated,
179
        ),
180
    }
181
    if not expected:
182
        row["stderr"] = capture.stderr_text[-4000:]
183
    if preserve_stdout:
184
        row["stdout"] = capture.stdout_text
185
    return row
186
187
188
def skip_row(
189
    subject: Subject,
190
    scenario: str,
191
    run_index: int,
192
    metadata: dict[str, Any],
193
    reason: str,
194
) -> dict[str, Any]:
195
    return {
196
        **metadata,
197
        "subject": subject.name,
198
        "subject_kind": subject.kind,
199
        "subject_label": subject.label,
200
        "scenario": scenario,
201
        "run": run_index,
202
        "operation": "task_loop.skipped",
203
        "step_kind": "summary",
204
        "elapsed_ms": 0.0,
205
        "returncode": SKIP_RETURNCODE,
206
        "command": [],
207
        "skipped": True,
208
        "skip_reason": reason,
209
        "tool_call_count": 0,
210
        "terminal_tool_call_count": 0,
211
        "vcs_tool_call_count": 0,
212
    }
213
214
215
def unmeasured_step_row(
216
    subject: Subject,
217
    scenario: str,
218
    operation: str,
219
    run_index: int,
220
    metadata: dict[str, Any],
221
    reason: str,
222
) -> dict[str, Any]:
223
    row = skip_row(subject, scenario, run_index, metadata, reason)
224
    row["operation"] = operation
225
    return row
226
227
228
def harness_step_row(
229
    subject: Subject,
230
    scenario: str,
231
    operation: str,
232
    run_index: int,
233
    metadata: dict[str, Any],
234
    elapsed_ms: float,
235
    *,
236
    returncode: int = 0,
237
    harness_command_count: int = 0,
238
) -> dict[str, Any]:
239
    return {
240
        **metadata,
241
        "subject": subject.name,
242
        "subject_kind": subject.kind,
243
        "subject_label": subject.label,
244
        "scenario": scenario,
245
        "run": run_index,
246
        "operation": operation,
247
        "step_kind": "harness",
248
        "elapsed_ms": round(elapsed_ms, 3),
249
        "returncode": returncode,
250
        "command": [],
251
        "tool_call_count": 0,
252
        "terminal_tool_call_count": 0,
253
        "vcs_tool_call_count": 0,
254
        "harness_command_count": harness_command_count,
255
        "stdout_bytes": 0,
256
        "stderr_bytes": 0,
257
        "retry_count": 0,
258
        "human_action_count": 0,
259
    }
260
261
262
def phase_total_row(
263
    rows: list[dict[str, Any]],
264
    subject: Subject,
265
    scenario: str,
266
    run_index: int,
267
    metadata: dict[str, Any],
268
    operation: str,
269
) -> dict[str, Any]:
270
    failures = [row for row in rows if row_returncode(row) not in (0, SKIP_RETURNCODE)]
271
    unmeasured = [
272
        str(row["operation"])
273
        for row in rows
274
        if row_returncode(row) == SKIP_RETURNCODE
275
    ]
276
    phase_elapsed_ms: dict[str, float] = {}
277
    for row in rows:
278
        phase = str(row.get("operation", "unknown")).split(".", 1)[0]
279
        phase_elapsed_ms[phase] = phase_elapsed_ms.get(phase, 0.0) + float(
280
            row.get("elapsed_ms", 0.0)
281
        )
282
    return {
283
        **metadata,
284
        "subject": subject.name,
285
        "subject_kind": subject.kind,
286
        "subject_label": subject.label,
287
        "scenario": scenario,
288
        "run": run_index,
289
        "operation": operation,
290
        "step_kind": "summary",
291
        "elapsed_ms": round(sum(float(row.get("elapsed_ms", 0)) for row in rows), 3),
292
        "returncode": 1 if failures else 0,
293
        "coverage_complete": not unmeasured,
294
        "unmeasured_operations": unmeasured,
295
        "command": [row["command"] for row in rows],
296
        "tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows),
297
        "terminal_tool_call_count": sum(
298
            oakbench_tokens.int_or_zero(row.get("terminal_tool_call_count")) for row in rows
299
        ),
300
        "vcs_tool_call_count": sum(oakbench_tokens.int_or_zero(row.get("vcs_tool_call_count")) for row in rows),
301
        "agent_tool_call_count": sum(
302
            oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows
303
        ),
304
        "harness_command_count": sum(
305
            oakbench_tokens.int_or_zero(row.get("harness_command_count")) for row in rows
306
        ),
307
        "retry_count": sum(oakbench_tokens.int_or_zero(row.get("retry_count")) for row in rows),
308
        "human_action_count": sum(
309
            oakbench_tokens.int_or_zero(row.get("human_action_count")) for row in rows
310
        ),
311
        "phase_elapsed_ms": {
312
            phase: round(elapsed, 3) for phase, elapsed in sorted(phase_elapsed_ms.items())
313
        },
314
        **oakbench_tokens.summed_token_fields(
315
            rows, "sum_of_task_loop_steps_command_plus_admitted_output_chars_div_4"
316
        ),
317
        "summarized_operations": [row["operation"] for row in rows],
318
    }
319
320
321
def has_lane_failures(rows: list[dict[str, Any]]) -> bool:
322
    return any(row_returncode(row) not in (0, SKIP_RETURNCODE) for row in rows)
323
324
325
def enforce_oracle_result(total: dict[str, Any], oracle_ok: bool | None) -> dict[str, Any]:
326
    if oracle_ok is False:
327
        total["returncode"] = 1
328
        total["stderr"] = "oracle_payload_missing_from_remote_branch"
329
    return total
330
331
332
def interrupted_publication_fields(
333
    *,
334
    request_count: int,
335
    request_bytes: int | None,
336
    local_head_before: str | None,
337
    local_head_after: str | None,
338
    status_after: dict[str, Any] | None,
339
    request_complete_before_disconnect: bool,
340
) -> dict[str, Any]:
341
    """Evidence fields for a single interrupted mutation attempt.
342
343
    The fixture's receipt of a full request is ground truth for the harness,
344
    not a server durability receipt available to the CLI.  A disconnect after
345
    that point is therefore unknown to the client and must never imply replay.
346
    """
347
    return {
348
        "publication_outcome": "unknown" if request_complete_before_disconnect else "not_sent",
349
        "fixture_received_complete_request_before_disconnect": request_complete_before_disconnect,
350
        "mutation_requests_observed": request_count,
351
        "blind_replay_requests": max(0, request_count - 1),
352
        "network_request_bytes_observed": request_bytes,
353
        "local_head_preserved": bool(local_head_before) and local_head_before == local_head_after,
354
        "local_work_preserved": bool(status_after and status_after.get("needs_push")),
355
        "working_tree_dirty_after": None if status_after is None else status_after.get("dirty"),
356
    }
357
358
359
class _DisconnectAfterPushHandler(BaseHTTPRequestHandler):
360
    """Minimal loopback fixture for a complete request with a lost reply."""
361
362
    server_version = "oakbench-interruption/1"
363
364
    def log_message(self, _format: str, *_args: object) -> None:
365
        return
366
367
    def _json(self, status: int, payload: dict[str, Any]) -> None:
368
        encoded = json.dumps(payload).encode()
369
        self.send_response(status)
370
        self.send_header("Content-Type", "application/json")
371
        self.send_header("Content-Length", str(len(encoded)))
372
        self.end_headers()
373
        self.wfile.write(encoded)
374
375
    def do_GET(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
376
        self._json(404, {"error": "absent synthetic repo"})
377
378
    def do_POST(self) -> None:  # noqa: N802 - BaseHTTPRequestHandler API
379
        size = int(self.headers.get("Content-Length", "0"))
380
        body = self.rfile.read(size)
381
        server = self.server
382
        if self.path == "/api/repos":
383
            self._json(200, {"name": "repo"})
384
            return
385
        if self.path == "/api/oak/repo/push":
386
            with server.receipt_lock:  # type: ignore[attr-defined]
387
                server.push_request_count += 1  # type: ignore[attr-defined]
388
                server.push_request_bytes += len(body)  # type: ignore[attr-defined]
389
                server.request_complete_before_disconnect = True  # type: ignore[attr-defined]
390
            self.close_connection = True
391
            try:
392
                self.connection.shutdown(socket.SHUT_RDWR)
393
            except OSError:
394
                pass
395
            self.connection.close()
396
            return
397
        self._json(404, {"error": "unexpected synthetic path"})
398
399
400
def start_interrupted_publication_server() -> tuple[ThreadingHTTPServer, threading.Thread]:
401
    server = ThreadingHTTPServer(("127.0.0.1", 0), _DisconnectAfterPushHandler)
402
    server.receipt_lock = threading.Lock()  # type: ignore[attr-defined]
403
    server.push_request_count = 0  # type: ignore[attr-defined]
404
    server.push_request_bytes = 0  # type: ignore[attr-defined]
405
    server.request_complete_before_disconnect = False  # type: ignore[attr-defined]
406
    thread = threading.Thread(target=server.serve_forever, name="oakbench-interrupted-push", daemon=True)
407
    thread.start()
408
    return server, thread
409
410
411
def _parse_json_object(text: str | None) -> dict[str, Any] | None:
412
    try:
413
        value = json.loads(text or "")
414
    except (TypeError, json.JSONDecodeError):
415
        return None
416
    return value if isinstance(value, dict) else None
417
418
419
def run_interrupted_publication(
420
    subject: Subject,
421
    run_root: Path,
422
    run_index: int,
423
    metadata: dict[str, Any],
424
    admitted: int,
425
) -> list[dict[str, Any]]:
426
    """Run one real Oak push against a loopback server that drops its reply.
427
428
    The fixture observes request delivery but deliberately has no durable
429
    remote state. That makes the client's outcome unknown and lets the lane
430
    prove work preservation and absence of blind replay without production
431
    mutations or a fake success claim.
432
    """
433
    scenario = INTERRUPTED_PUBLICATION_SCENARIO
434
    metadata = {
435
        **metadata,
436
        "benchmark_track": "failure-evidence",
437
        "remote_transport": "loopback_fault_fixture",
438
        "remote_server": "127.0.0.1",
439
    }
440
    if subject.kind != "oak":
441
        return [
442
            skip_row(
443
                subject,
444
                scenario,
445
                run_index,
446
                metadata,
447
                "interrupted publication fixture currently exercises the actual Oak HTTP client only",
448
            )
449
        ]
450
451
    workspace = run_root / f"interrupted-{subject.name}-r{run_index}"
452
    workspace.mkdir(parents=True)
453
    rows: list[dict[str, Any]] = []
454
    vcs = str(subject.bin)
455
    rows.append(
456
        step_row(subject, scenario, "setup.init", [vcs, "init", "."], workspace, run_index, metadata, admitted)
457
    )
458
    if row_returncode(rows[-1]) != 0:
459
        return rows
460
    rows.append(
461
        edit_step(
462
            subject,
463
            scenario,
464
            "setup.edit",
465
            workspace,
466
            f"interrupted publication run={run_index}",
467
            run_index,
468
            metadata,
469
            admitted,
470
        )
471
    )
472
    rows.append(
473
        step_row(subject, scenario, "setup.checkpoint", [vcs, "commit"], workspace, run_index, metadata, admitted)
474
    )
475
    if row_returncode(rows[-1]) != 0:
476
        return rows
477
478
    before = run_untimed([vcs, "hash"], workspace)
479
    head_before = before.stdout.strip() if before.returncode == 0 else None
480
    before_usage = bounded_tree_usage(workspace)
481
    push_help = run_untimed([vcs, "push", "--help"], workspace)
482
    push_supports_json = "--json" in (push_help.stdout + push_help.stderr)
483
    server, thread = start_interrupted_publication_server()
484
    host, port = server.server_address[:2]
485
    remote = f"http://{host}:{port}"
486
    fixture_env = base_env()
487
    fixture_env.pop("OAK_API_KEY", None)
488
    fixture_env.pop("OAK_REMOTE", None)
489
    fixture_env["OAK_NO_UPDATE_CHECK"] = "1"
490
    try:
491
        push_command = [vcs, "push", "--repo", "oak/repo", "--remote", remote]
492
        if push_supports_json:
493
            push_command.append("--json")
494
        rows.append(
495
            step_row(
496
                subject,
497
                scenario,
498
                "publication.attempt",
499
                push_command,
500
                workspace,
501
                run_index,
502
                metadata,
503
                admitted,
504
                expected_returncodes=tuple(range(1, 128)),
505
                env=fixture_env,
506
            )
507
        )
508
    finally:
509
        server.shutdown()
510
        server.server_close()
511
        thread.join(timeout=2.0)
512
513
    state_row = step_row(
514
        subject,
515
        scenario,
516
        "reconcile.local_state",
517
        [vcs, "agent", "state", "--json", "--compact"],
518
        workspace,
519
        run_index,
520
        metadata,
521
        admitted,
522
        preserve_stdout=True,
523
        env=fixture_env,
524
    )
525
    rows.append(state_row)
526
    after = run_untimed([vcs, "hash"], workspace)
527
    head_after = after.stdout.strip() if after.returncode == 0 else None
528
    status_after = _parse_json_object(state_row.get("stdout"))
529
    with server.receipt_lock:  # type: ignore[attr-defined]
530
        request_count = int(server.push_request_count)  # type: ignore[attr-defined]
531
        request_bytes = int(server.push_request_bytes)  # type: ignore[attr-defined]
532
        request_complete = bool(server.request_complete_before_disconnect)  # type: ignore[attr-defined]
533
    evidence = interrupted_publication_fields(
534
        request_count=request_count,
535
        request_bytes=request_bytes if request_count else None,
536
        local_head_before=head_before,
537
        local_head_after=head_after,
538
        status_after=status_after,
539
        request_complete_before_disconnect=request_complete,
540
    )
541
    evidence["workspace_usage_before"] = before_usage
542
    evidence["workspace_usage_after"] = bounded_tree_usage(workspace)
543
    evidence["remote_transport"] = "loopback_fault_fixture"
544
    evidence["remote_server"] = str(host)
545
    evidence["human_action_count"] = 0
546
    evidence["compatibility_probe_calls"] = 1
547
    evidence["harness_probe_calls"] = 3
548
    evidence["push_json_supported"] = push_supports_json
549
    total = phase_total_row(rows, subject, scenario, run_index, metadata, "interrupted_publication.total")
550
    evidence["harness_command_count"] = total["harness_command_count"] + 3
551
    total.update(evidence)
552
    if not (
553
        request_complete
554
        and request_count == 1
555
        and evidence["local_head_preserved"]
556
        and evidence["local_work_preserved"]
557
        and row_returncode(rows[-1]) == 0
558
    ):
559
        total["returncode"] = 1
560
        total["stderr"] = "interrupted_publication_evidence_incomplete"
561
    rows.append(total)
562
    return rows
563
564
565
def edit_step(
566
    subject: Subject,
567
    scenario: str,
568
    operation: str,
569
    workspace: Path,
570
    payload: str,
571
    run_index: int,
572
    metadata: dict[str, Any],
573
    admitted: int,
574
) -> dict[str, Any]:
575
    command = ["/bin/zsh", "-c", f"echo {payload!r} >> task-notes.md"]
576
    return step_row(
577
        subject, scenario, operation, command, workspace, run_index, metadata, admitted, kind="edit"
578
    )
579
580
581
def oak_virtual_branch(subject: Subject, mount_dir: Path) -> str | None:
582
    proc = run_untimed([str(subject.bin), "status"], mount_dir)
583
    match = VIRTUAL_BRANCH_RE.search(proc.stdout + proc.stderr)
584
    return match.group(1) if match else None
585
586
587
def run_oak_loop(
588
    subject: Subject,
589
    remote: oakbench_remotes.RemoteResolution,
590
    run_root: Path,
591
    run_index: int,
592
    metadata: dict[str, Any],
593
    admitted: int,
594
) -> list[dict[str, Any]]:
595
    scenario = "task_loop"
596
    meta = {**metadata, **remote.row_fields()}
597
    rows: list[dict[str, Any]] = []
598
    branches: dict[str, str | None] = {}
599
    payloads: dict[str, str] = {}
600
    mounts: list[Path] = []
601
    vcs = str(subject.bin)
602
    try:
603
        for phase in PHASES:
604
            workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
605
            payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
606
            payloads[phase] = payload
607
            acquire = step_row(
608
                subject, scenario, f"{phase}.acquire", [vcs, "mount", remote.repo or "", str(workspace)],
609
                run_root, run_index, meta, admitted,
610
            )
611
            rows.append(acquire)
612
            if int(acquire["returncode"]) != 0:
613
                return rows
614
            mounts.append(workspace)
615
            branches[phase] = oak_virtual_branch(subject, workspace)
616
            rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
617
            rows.append(step_row(subject, scenario, f"{phase}.snapshot", [vcs, "commit", "--no-verify"], workspace, run_index, meta, admitted))
618
            rows.append(step_row(subject, scenario, f"{phase}.publish", [vcs, "push"], workspace, run_index, meta, admitted))
619
            if phase == "task":
620
                rows.append(
621
                    step_row(
622
                        subject, scenario, "task.describe",
623
                        [vcs, "desc", f"bench task loop {metadata['bench_id']} r{run_index}"],
624
                        workspace, run_index, meta, admitted,
625
                    )
626
                )
627
            end = step_row(
628
                subject, scenario, f"{phase}.end", [vcs, "mount", "end", str(workspace)],
629
                run_root, run_index, meta, admitted,
630
            )
631
            rows.append(end)
632
            if int(end["returncode"]) == 0 and workspace in mounts:
633
                mounts.remove(workspace)
634
635
        # Untimed oracle: each phase's payload must be on its pushed branch.
636
        verifier = run_root / f"tl-verify-{subject.name}-r{run_index}"
637
        clone = run_untimed([vcs, "clone", remote.repo or "", str(verifier)], run_root)
638
        oracle_ok: bool | None = None
639
        if clone.returncode == 0:
640
            oracle_ok = True
641
            for phase in PHASES:
642
                branch = branches.get(phase)
643
                if not branch:
644
                    oracle_ok = None
645
                    break
646
                switch = run_untimed([vcs, "switch", branch], verifier)
647
                content = (verifier / "task-notes.md")
648
                seen = content.read_text() if content.exists() else ""
649
                if switch.returncode != 0 or payloads[phase] not in seen:
650
                    oracle_ok = False
651
        total = {
652
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
653
            "oracle_payload_on_remote_branch": oracle_ok,
654
            "workspace_branches": branches,
655
            "measurement_source": "verifier_clone_switch_grep; null oracle means branch name unparsed",
656
        }
657
        rows.append(enforce_oracle_result(total, oracle_ok))
658
        return rows
659
    finally:
660
        for mount_dir in mounts:
661
            run_untimed([vcs, "mount", "end", str(mount_dir), "-f"], run_root)
662
663
664
def ensure_git_remote_seeded(subject: Subject, remote: oakbench_remotes.RemoteResolution, scratch: Path) -> str | None:
665
    """Untimed: make sure the git remote has a main with a seed commit."""
666
    vcs = str(subject.bin)
667
    probe = run_untimed([vcs, "ls-remote", str(remote.repo), "refs/heads/main"], None)
668
    if probe.returncode == 0 and probe.stdout.strip():
669
        if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE:
670
            head = run_untimed([vcs, "symbolic-ref", "HEAD", "refs/heads/main"], Path(str(remote.repo)))
671
            if head.returncode != 0:
672
                return f"git remote HEAD setup failed: {head.stderr.strip()[:200]}"
673
        return None
674
    seed = scratch / "git-seed"
675
    if seed.exists():
676
        shutil.rmtree(seed)
677
    seed.mkdir(parents=True)
678
    (seed / "README.md").write_text("Task-loop benchmark repo (disposable).\n")
679
    (seed / "task-notes.md").write_text("# task notes\n")
680
    for command in (
681
        [vcs, "init", "-q", "-b", "main"],
682
        [vcs, "add", "."],
683
        [vcs, "commit", "-q", "-m", "seed"],
684
        [vcs, "push", "-q", str(remote.repo), "main:main"],
685
    ):
686
        proc = run_untimed(command, seed)
687
        if proc.returncode != 0:
688
            return f"git remote seed `{' '.join(command[1:3])}` failed: {proc.stderr.strip()[:200]}"
689
    if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE:
690
        head = run_untimed([vcs, "symbolic-ref", "HEAD", "refs/heads/main"], Path(str(remote.repo)))
691
        if head.returncode != 0:
692
            return f"git remote HEAD setup failed: {head.stderr.strip()[:200]}"
693
    return None
694
695
696
def run_git_agent_task_baseline(
697
    subject: Subject,
698
    remote: oakbench_remotes.RemoteResolution,
699
    run_root: Path,
700
    run_index: int,
701
    metadata: dict[str, Any],
702
    admitted: int,
703
    remote_create_ms: float = 0.0,
704
) -> list[dict[str, Any]]:
705
    """Measure a cold clone and a warm isolated worktree on one local remote.
706
707
    This is the core-equivalent, local-file cell. Hosted CI is deliberately a
708
    skip row: a local commit oracle is not relabelled as provider CI.
709
    """
710
    scenario = AGENT_TASK_SCENARIO
711
    meta = {
712
        **metadata,
713
        **remote.row_fields(),
714
        "benchmark_track": (
715
            "core-equivalent"
716
            if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE
717
            else "agent-default"
718
        ),
719
    }
720
    vcs = str(subject.bin)
721
    rows: list[dict[str, Any]] = [
722
        harness_step_row(
723
            subject,
724
            scenario,
725
            "setup.remote_container",
726
            run_index,
727
            meta,
728
            remote_create_ms,
729
            harness_command_count=1,
730
        )
731
    ]
732
    setup_start = time.perf_counter()
733
    seed_error = ensure_git_remote_seeded(subject, remote, run_root)
734
    rows.append(
735
        harness_step_row(
736
            subject,
737
            scenario,
738
            "setup.remote_seed",
739
            run_index,
740
            meta,
741
            (time.perf_counter() - setup_start) * 1000,
742
            returncode=1 if seed_error else 0,
743
            harness_command_count=6,
744
        )
745
    )
746
    if seed_error:
747
        rows[-1]["stderr"] = seed_error
748
        return rows
749
750
    base = run_root / f"agent-task-{subject.name}-r{run_index}-cold"
751
    cold_branch = oakbench_remotes.disposable_branch(
752
        str(metadata["bench_id"]), "agent-task-cold", subject.name, run_index
753
    )
754
    warm_branch = oakbench_remotes.disposable_branch(
755
        str(metadata["bench_id"]), "agent-task-warm", subject.name, run_index
756
    )
757
    cold_payload = f"cold task run={run_index} bench={metadata['bench_id']}"
758
    warm_payload = f"warm task run={run_index} bench={metadata['bench_id']}"
759
760
    rows.append(
761
        step_row(
762
            subject,
763
            scenario,
764
            "cold.acquire",
765
            [vcs, "clone", "-q", str(remote.repo), str(base)],
766
            run_root,
767
            run_index,
768
            meta,
769
            admitted,
770
        )
771
    )
772
    if row_returncode(rows[-1]) != 0:
773
        return rows
774
    rows.append(
775
        step_row(
776
            subject, scenario, "cold.branch", [vcs, "switch", "-q", "-c", cold_branch],
777
            base, run_index, meta, admitted,
778
        )
779
    )
780
    rows.append(
781
        step_row(
782
            subject, scenario, "cold.inspect", [vcs, "status", "--porcelain=v1"],
783
            base, run_index, meta, admitted,
784
        )
785
    )
786
    rows.append(edit_step(subject, scenario, "cold.edit", base, cold_payload, run_index, meta, admitted))
787
    rows.append(step_row(subject, scenario, "cold.checkpoint.add", [vcs, "add", "."], base, run_index, meta, admitted))
788
    rows.append(
789
        step_row(
790
            subject, scenario, "cold.checkpoint.commit",
791
            [vcs, "commit", "-q", "-m", f"agent task cold {metadata['bench_id']} r{run_index}"],
792
            base, run_index, meta, admitted,
793
        )
794
    )
795
    rows.append(
796
        step_row(
797
            subject, scenario, "cold.publish", [vcs, "push", "-q", "-u", "origin", cold_branch],
798
            base, run_index, meta, admitted,
799
        )
800
    )
801
    rows.append(
802
        unmeasured_step_row(
803
            subject, scenario, "cold.ci.observe", run_index, meta,
804
            "local-file transport has no hosted CI provider; exact CI observation is unmeasured",
805
        )
806
    )
807
    rows.append(
808
        step_row(
809
            subject, scenario, "cold.review_handoff",
810
            [vcs, "show", "--format=%H%n%s", "--stat", "--oneline", "HEAD"],
811
            base, run_index, meta, admitted,
812
        )
813
    )
814
815
    warm = run_root / f"agent-task-{subject.name}-r{run_index}-warm"
816
    rows.append(
817
        step_row(
818
            subject, scenario, "warm.acquire",
819
            [vcs, "worktree", "add", "-q", str(warm), "-b", warm_branch, "origin/main"],
820
            base, run_index, meta, admitted,
821
        )
822
    )
823
    if row_returncode(rows[-1]) == 0:
824
        rows.append(
825
            step_row(
826
                subject, scenario, "warm.inspect", [vcs, "status", "--porcelain=v1"],
827
                warm, run_index, meta, admitted,
828
            )
829
        )
830
        rows.append(edit_step(subject, scenario, "warm.edit", warm, warm_payload, run_index, meta, admitted))
831
        rows.append(step_row(subject, scenario, "warm.checkpoint.add", [vcs, "add", "."], warm, run_index, meta, admitted))
832
        rows.append(
833
            step_row(
834
                subject, scenario, "warm.checkpoint.commit",
835
                [vcs, "commit", "-q", "-m", f"agent task warm {metadata['bench_id']} r{run_index}"],
836
                warm, run_index, meta, admitted,
837
            )
838
        )
839
        rows.append(
840
            step_row(
841
                subject, scenario, "warm.publish", [vcs, "push", "-q", "-u", "origin", warm_branch],
842
                warm, run_index, meta, admitted,
843
            )
844
        )
845
        rows.append(
846
            unmeasured_step_row(
847
                subject, scenario, "warm.ci.observe", run_index, meta,
848
                "local-file transport has no hosted CI provider; exact CI observation is unmeasured",
849
            )
850
        )
851
        rows.append(
852
            step_row(
853
                subject, scenario, "warm.review_handoff",
854
                [vcs, "show", "--format=%H%n%s", "--stat", "--oneline", "HEAD"],
855
                warm, run_index, meta, admitted,
856
            )
857
        )
858
859
    usage_before_cleanup = {
860
        "cold": bounded_tree_usage(base),
861
        "warm": bounded_tree_usage(warm),
862
    }
863
    oracle_ok = True
864
    for branch, payload in ((cold_branch, cold_payload), (warm_branch, warm_payload)):
865
        show = run_untimed([vcs, "--git-dir", str(remote.repo), "show", f"{branch}:task-notes.md"], run_root)
866
        if show.returncode != 0 or payload not in show.stdout:
867
            oracle_ok = False
868
869
    if warm.exists():
870
        rows.append(
871
            step_row(
872
                subject, scenario, "cleanup.warm",
873
                [vcs, "worktree", "remove", str(warm)], base, run_index, meta, admitted,
874
            )
875
        )
876
    cleanup_start = time.perf_counter()
877
    shutil.rmtree(base, ignore_errors=True)
878
    rows.append(
879
        harness_step_row(
880
            subject, scenario, "cleanup.cold", run_index, meta,
881
            (time.perf_counter() - cleanup_start) * 1000,
882
        )
883
    )
884
885
    total = phase_total_row(rows, subject, scenario, run_index, meta, "agent_task.total")
886
    total.update(
887
        {
888
            "oracle_payload_on_remote_branch": oracle_ok,
889
            "acquisition_models": {
890
                "cold": "fresh_full_clone",
891
                "warm": "shared_object_worktree",
892
            },
893
            "repository_cache_state": {
894
                "cold": "fresh_destination_no_shared_object_store",
895
                "warm": "explicit_shared_object_store",
896
            },
897
            "os_cache_state": "unknown",
898
            "os_cache_state_reason": "page_cache_not_purged_or_instrumented",
899
            "workspace_usage_before_cleanup": usage_before_cleanup,
900
            "network_bytes": None,
901
            "network_bytes_reason": "local_file_transport_not_observed_by_byte_proxy",
902
            "partial_clone_comparator": None,
903
            "partial_clone_reason": "local file transport does not establish a network partial-clone comparison",
904
            "human_action_count": 0,
905
            "parallel_evidence_lane": "parallel_contention.py/workspace_per_task",
906
            "oracle_probe_calls": 2,
907
            "harness_command_count": total["harness_command_count"] + 2,
908
        }
909
    )
910
    if not oracle_ok:
911
        total["returncode"] = 1
912
        total["stderr"] = "agent_task_remote_payload_oracle_failed"
913
    rows.append(total)
914
    return rows
915
916
917
def run_git_loop(
918
    subject: Subject,
919
    remote: oakbench_remotes.RemoteResolution,
920
    run_root: Path,
921
    run_index: int,
922
    metadata: dict[str, Any],
923
    admitted: int,
924
) -> list[dict[str, Any]]:
925
    scenario = "task_loop"
926
    meta = {
927
        **metadata,
928
        **remote.row_fields(),
929
        "benchmark_track": (
930
            "core-equivalent"
931
            if remote.transport == oakbench_remotes.TRANSPORT_LOCAL_FILE
932
            else "agent-default"
933
        ),
934
    }
935
    vcs = str(subject.bin)
936
    rows: list[dict[str, Any]] = []
937
938
    seed_error = ensure_git_remote_seeded(subject, remote, run_root)
939
    if seed_error:
940
        return [skip_row(subject, scenario, run_index, meta, seed_error)]
941
942
    # Untimed: the base clone is the long-lived checkout worktrees hang off;
943
    # an agent fleet pays it once, not per task.
944
    base = run_root / f"tl-git-base-r{run_index}"
945
    clone = run_untimed([vcs, "clone", "-q", str(remote.repo), str(base)], run_root)
946
    if clone.returncode != 0:
947
        return [skip_row(subject, scenario, run_index, meta, f"base clone failed: {clone.stderr.strip()[:200]}")]
948
949
    branches: dict[str, str] = {}
950
    payloads: dict[str, str] = {}
951
    for phase in PHASES:
952
        workspace = run_root / f"tl-{subject.name}-r{run_index}-{phase}"
953
        branch = oakbench_remotes.disposable_branch(
954
            str(metadata["bench_id"]), f"taskloop-{phase}", subject.name, run_index
955
        )
956
        branches[phase] = branch
957
        payload = f"payload {phase} run={run_index} bench={metadata['bench_id']}"
958
        payloads[phase] = payload
959
        acquire = step_row(
960
            subject, scenario, f"{phase}.acquire",
961
            [vcs, "worktree", "add", str(workspace), "-b", branch], base, run_index, meta, admitted,
962
        )
963
        rows.append(acquire)
964
        if int(acquire["returncode"]) != 0:
965
            return rows
966
        rows.append(edit_step(subject, scenario, f"{phase}.edit", workspace, payload, run_index, meta, admitted))
967
        # Agent-default snapshot: git pays stage+commit as two calls.
968
        rows.append(step_row(subject, scenario, f"{phase}.snapshot.add", [vcs, "add", "."], workspace, run_index, meta, admitted))
969
        rows.append(
970
            step_row(
971
                subject, scenario, f"{phase}.snapshot.commit",
972
                [vcs, "commit", "-q", "-m", f"bench task loop {metadata['bench_id']} r{run_index} {phase}"],
973
                workspace, run_index, meta, admitted,
974
            )
975
        )
976
        rows.append(
977
            step_row(
978
                subject, scenario, f"{phase}.publish",
979
                [vcs, "push", "-q", "-u", "origin", branch], workspace, run_index, meta, admitted,
980
            )
981
        )
982
        # No task.describe: git's description is the commit message already
983
        # paid in {phase}.snapshot.commit (asymmetry documented in the lane doc).
984
        rows.append(
985
            step_row(
986
                subject, scenario, f"{phase}.end",
987
                [vcs, "worktree", "remove", str(workspace)], base, run_index, meta, admitted,
988
            )
989
        )
990
991
    # Untimed oracle: payloads visible on the remote branches.
992
    oracle_ok: bool | None = True
993
    for phase in PHASES:
994
        fetch = run_untimed([vcs, "fetch", "-q", "origin", branches[phase]], base)
995
        show = run_untimed([vcs, "show", f"FETCH_HEAD:task-notes.md"], base)
996
        if fetch.returncode != 0 or payloads[phase] not in show.stdout:
997
            oracle_ok = False
998
    rows.append(
999
        {
1000
            **phase_total_row(rows, subject, scenario, run_index, meta, "task_loop.total"),
1001
            "oracle_payload_on_remote_branch": oracle_ok,
1002
            "workspace_branches": branches,
1003
            "measurement_source": "fetch_plus_show_grep",
1004
        }
1005
    )
1006
    return rows
1007
1008
1009
def loop_summary_fields(rows: list[dict[str, Any]]) -> dict[str, Any]:
1010
    """followup-vs-task warm deltas computed from this run's step rows."""
1011
    def op_ms(operation: str) -> float | None:
1012
        for row in rows:
1013
            if row.get("operation") == operation:
1014
                return float(row.get("elapsed_ms", 0.0))
1015
        return None
1016
1017
    def phase_ms(prefix: str) -> float:
1018
        return sum(
1019
            float(row.get("elapsed_ms", 0.0))
1020
            for row in rows
1021
            if str(row.get("operation", "")).startswith(prefix + ".")
1022
        )
1023
1024
    task_acquire = op_ms("task.acquire")
1025
    followup_acquire = op_ms("followup.acquire")
1026
    fields: dict[str, Any] = {
1027
        "task_phase_ms": round(phase_ms("task"), 3),
1028
        "followup_phase_ms": round(phase_ms("followup"), 3),
1029
    }
1030
    if task_acquire is not None and followup_acquire is not None:
1031
        fields["followup_warm_delta_ms"] = round(followup_acquire - task_acquire, 3)
1032
        fields["acquire_ms_task"] = round(task_acquire, 3)
1033
        fields["acquire_ms_followup"] = round(followup_acquire, 3)
1034
    else:
1035
        fields["followup_warm_delta_ms"] = None
1036
    return fields
1037
1038
1039
def summary_text(rows: list[dict[str, Any]]) -> str:
1040
    totals = [row for row in rows if row.get("operation") == "task_loop.total"]
1041
    lines = [
1042
        "# Task Loop Summary",
1043
        "",
1044
        "Whole-task cost of the signature motion: acquire workspace -> edit -> snapshot ->",
1045
        "publish -> describe -> end, then a follow-up task in a fresh workspace.",
1046
        "Command sequences are NOT step-equivalent across subjects",
1047
        "(oak pays desc; git's description is its commit message). Latency across unlike",
1048
        "transports is never comparable; tool calls and tokens are.",
1049
        "",
1050
        "| Subject | Run | Transport | Wall ms | Tool calls | Cost-wt tokens (env) | Warm acquire delta ms | Oracle |",
1051
        "| --- | ---: | --- | ---: | ---: | ---: | ---: | --- |",
1052
    ]
1053
    for row in totals:
1054
        oracle = row.get("oracle_payload_on_remote_branch")
1055
        lines.append(
1056
            "| `{}` | {} | {} | {} | {} | {} | {} | {} |".format(
1057
                row["subject"],
1058
                row["run"],
1059
                row.get("remote_transport", "unmeasured"),
1060
                fmt_ms(row.get("elapsed_ms")),
1061
                row.get("tool_call_count", "unmeasured"),
1062
                row.get("estimated_cost_weighted_tokens_with_envelope", "unmeasured"),
1063
                row.get("followup_warm_delta_ms", "unmeasured"),
1064
                "pass" if oracle else ("FAIL" if oracle is False else "unmeasured"),
1065
            )
1066
        )
1067
    skips = [
1068
        row for row in rows
1069
        if row.get("skipped") and row.get("scenario") == TASK_LOOP_SCENARIO
1070
    ]
1071
    for row in skips:
1072
        lines.append(f"| `{row['subject']}` | {row['run']} | skipped | | | | | {row['skip_reason'][:70]} |")
1073
1074
    agent_totals = [row for row in rows if row.get("operation") == "agent_task.total"]
1075
    if agent_totals:
1076
        lines.extend(
1077
            [
1078
                "",
1079
                "## Cold clone and warm isolated task",
1080
                "",
1081
                "Local-file Git rows compare a fresh full clone with a worktree sharing",
1082
                "the first clone's object store. OS cache state and network bytes remain",
1083
                "unknown. Hosted CI is a skip, not a local-oracle success.",
1084
                "",
1085
                "| Subject | Run | Wall ms | Calls | Output bytes | Coverage | Oracle |",
1086
                "| --- | ---: | ---: | ---: | ---: | --- | --- |",
1087
            ]
1088
        )
1089
        for row in agent_totals:
1090
            lines.append(
1091
                "| `{}` | {} | {} | {} | {} | {} | {} |".format(
1092
                    row["subject"],
1093
                    row["run"],
1094
                    fmt_ms(row.get("elapsed_ms")),
1095
                    row.get("tool_call_count", "unmeasured"),
1096
                    row.get("raw_output_bytes", "unmeasured"),
1097
                    "complete" if row.get("coverage_complete") else "partial",
1098
                    "pass" if row.get("oracle_payload_on_remote_branch") else "FAIL",
1099
                )
1100
            )
1101
1102
    interrupted_totals = [
1103
        row for row in rows if row.get("operation") == "interrupted_publication.total"
1104
    ]
1105
    if interrupted_totals:
1106
        lines.extend(
1107
            [
1108
                "",
1109
                "## Interrupted publication",
1110
                "",
1111
                "A loopback fixture reads one complete ordinary Oak push request and drops",
1112
                "the response. `unknown` is the client-side outcome; fixture request receipt",
1113
                "is not a durability receipt and the harness never retries the mutation.",
1114
                "",
1115
                "| Subject | Run | Wall ms | Calls | Output bytes | Outcome | POSTs | Request bytes | Head/work preserved |",
1116
                "| --- | ---: | ---: | ---: | ---: | --- | ---: | ---: | --- |",
1117
            ]
1118
        )
1119
        for row in interrupted_totals:
1120
            lines.append(
1121
                "| `{}` | {} | {} | {} | {} | `{}` | {} | {} | {}/{} |".format(
1122
                    row["subject"],
1123
                    row["run"],
1124
                    fmt_ms(row.get("elapsed_ms")),
1125
                    row.get("tool_call_count", "unmeasured"),
1126
                    row.get("raw_output_bytes", "unmeasured"),
1127
                    row.get("publication_outcome", "unmeasured"),
1128
                    row.get("mutation_requests_observed", "unmeasured"),
1129
                    row.get("network_request_bytes_observed", "unmeasured"),
1130
                    "yes" if row.get("local_head_preserved") else "no",
1131
                    "yes" if row.get("local_work_preserved") else "no",
1132
                )
1133
            )
1134
    return "\n".join(lines) + "\n"
1135
1136
1137
def fmt_ms(value: Any) -> str:
1138
    try:
1139
        return f"{float(value):.0f}"
1140
    except (TypeError, ValueError):
1141
        return "unmeasured"
1142
1143
1144
def main() -> int:
1145
    args = parse_args()
1146
    scenarios = [item.strip() for item in args.scenarios.split(",") if item.strip()]
1147
    known_scenarios = {TASK_LOOP_SCENARIO, AGENT_TASK_SCENARIO, INTERRUPTED_PUBLICATION_SCENARIO}
1148
    unknown_scenarios = sorted(set(scenarios) - known_scenarios)
1149
    if unknown_scenarios:
1150
        raise SystemExit(
1151
            "Unknown scenarios: " + ", ".join(unknown_scenarios)
1152
            + "; valid: " + ", ".join(sorted(known_scenarios))
1153
        )
1154
    subjects = load_subjects(args)
1155
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1156
    args.results.mkdir(parents=True, exist_ok=True)
1157
    args.workdir.mkdir(parents=True, exist_ok=True)
1158
    run_root = args.workdir / "runs" / timestamp
1159
    run_root.mkdir(parents=True, exist_ok=True)
1160
1161
    metadata = {
1162
        "bench_id": timestamp,
1163
        "profile": "task-loop",
1164
        "benchmark_track": "agent-default",
1165
        "timestamp_utc": timestamp,
1166
        "host": platform.node(),
1167
        "platform": platform.platform(),
1168
        "machine": platform.machine(),
1169
        "python": platform.python_version(),
1170
        "env_isolation_version": ENV_ISOLATION_VERSION,
1171
        "subject_versions": subject_versions(subjects),
1172
        "subject_details": subject_details(subjects),
1173
        "source": source_metadata(args.oak_repo),
1174
        "git_remote_mode": args.git_remote,
1175
        "trial_order_randomized": args.randomize_subject_order,
1176
        "trial_order_seed": args.random_seed if args.randomize_subject_order else None,
1177
    }
1178
1179
    rows: list[dict[str, Any]] = []
1180
    with measurement_lock("task_loop") as lock_info:
1181
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
1182
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
1183
        order_rng = random.Random(args.random_seed)
1184
        for run_index in range(args.runs):
1185
            run_subjects = list(subjects)
1186
            if args.randomize_subject_order:
1187
                order_rng.shuffle(run_subjects)
1188
            run_metadata = {
1189
                **metadata,
1190
                "run_subject_order": [subject.name for subject in run_subjects],
1191
            }
1192
            for subject in run_subjects:
1193
                for scenario in scenarios:
1194
                    print(f"[run] {scenario} run={run_index} subject={subject.name}", flush=True)
1195
                    if scenario == INTERRUPTED_PUBLICATION_SCENARIO:
1196
                        rows.extend(
1197
                            run_interrupted_publication(
1198
                                subject, run_root, run_index, run_metadata, args.admitted_output_chars
1199
                            )
1200
                        )
1201
                        continue
1202
                    if scenario == AGENT_TASK_SCENARIO:
1203
                        if subject.kind != "git":
1204
                            rows.append(
1205
                                unmeasured_step_row(
1206
                                    subject,
1207
                                    scenario,
1208
                                    "agent_task.skipped",
1209
                                    run_index,
1210
                                    run_metadata,
1211
                                    "Oak end-to-end publication and exact CI require an explicitly configured disposable staging lane; production mutation is forbidden",
1212
                                )
1213
                            )
1214
                            continue
1215
                        remote_create_start = time.perf_counter()
1216
                        remote = oakbench_remotes.make_git_bare_remote(
1217
                            subject.bin, run_root, f"agent-task-remote-r{run_index}.git"
1218
                        )
1219
                        remote_create_ms = (time.perf_counter() - remote_create_start) * 1000
1220
                        rows.extend(
1221
                            run_git_agent_task_baseline(
1222
                                subject,
1223
                                remote,
1224
                                run_root,
1225
                                run_index,
1226
                                run_metadata,
1227
                                args.admitted_output_chars,
1228
                                remote_create_ms,
1229
                            )
1230
                        )
1231
                        continue
1232
                    if subject.kind == "git":
1233
                        if args.git_remote == "github":
1234
                            remote = oakbench_remotes.resolve_git_github_remote()
1235
                        else:
1236
                            remote = oakbench_remotes.make_git_bare_remote(
1237
                                subject.bin, run_root, f"task-loop-remote-r{run_index}.git"
1238
                            )
1239
                        if not remote.resolved:
1240
                            rows.append(skip_row(subject, TASK_LOOP_SCENARIO, run_index, run_metadata, remote.skip_reason or ""))
1241
                            continue
1242
                        subject_rows = run_git_loop(subject, remote, run_root, run_index, run_metadata, args.admitted_output_chars)
1243
                    else:
1244
                        remote = oakbench_remotes.resolve_oak_remote("sync")
1245
                        if not remote.resolved:
1246
                            rows.append(skip_row(subject, TASK_LOOP_SCENARIO, run_index, run_metadata, remote.skip_reason or ""))
1247
                            continue
1248
                        subject_rows = run_oak_loop(subject, remote, run_root, run_index, run_metadata, args.admitted_output_chars)
1249
                    total = next((row for row in subject_rows if row.get("operation") == "task_loop.total"), None)
1250
                    if total is not None:
1251
                        total.update(loop_summary_fields(subject_rows))
1252
                    rows.extend(subject_rows)
1253
1254
    store = ResultsStore(args.results, lane="task-loop")
1255
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows))
1256
    if not args.keep_workdirs:
1257
        shutil.rmtree(run_root, ignore_errors=True)
1258
    print(f"[result] {raw_path}")
1259
    print(f"[summary] {summary_path}")
1260
    return 1 if has_lane_failures(rows) else 0
1261
1262
1263
if __name__ == "__main__":
1264
    raise SystemExit(main())